diff --git a/CHANGES.txt b/CHANGES.txt index 891ff1d11..c4f607015 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,6 @@ 1.0.0 ----- + * Add RBAC Authorization support in Sidecar (CASSSIDECAR-161) * Mechanism to have a reduced number of Sidecar instances run operations (CASSSIDECAR-174) * Adding support for CDC APIs into sidecar client (CASSSIDECAR-172) * Stopping Sidecar can take a long time (CASSSIDECAR-178) diff --git a/adapters/base/src/main/java/org/apache/cassandra/sidecar/adapters/base/db/schema/ConnectedClientsSchema.java b/adapters/base/src/main/java/org/apache/cassandra/sidecar/adapters/base/db/schema/ConnectedClientsSchema.java index ad6fdcace..28a4c0d32 100644 --- a/adapters/base/src/main/java/org/apache/cassandra/sidecar/adapters/base/db/schema/ConnectedClientsSchema.java +++ b/adapters/base/src/main/java/org/apache/cassandra/sidecar/adapters/base/db/schema/ConnectedClientsSchema.java @@ -48,6 +48,13 @@ public void prepareStatements(@NotNull Session session) connectionsByUserStatement = prepare(connectionsByUserStatement, session, selectConnectionsByUserStatement()); } + @Override + protected void unprepareStatements() + { + statsStatement = null; + connectionsByUserStatement = null; + } + @Override protected String tableName() { diff --git a/client-common/src/main/java/org/apache/cassandra/sidecar/common/ApiEndpointsV1.java b/client-common/src/main/java/org/apache/cassandra/sidecar/common/ApiEndpointsV1.java index 108bc6e13..2da82bb09 100644 --- a/client-common/src/main/java/org/apache/cassandra/sidecar/common/ApiEndpointsV1.java +++ b/client-common/src/main/java/org/apache/cassandra/sidecar/common/ApiEndpointsV1.java @@ -32,8 +32,10 @@ public final class ApiEndpointsV1 public static final String NATIVE = "/native"; public static final String JMX = "/jmx"; - public static final String KEYSPACE_PATH_PARAM = ":keyspace"; - public static final String TABLE_PATH_PARAM = ":table"; + public static final String KEYSPACE = "keyspace"; + public static final String TABLE = "table"; + public static final String KEYSPACE_PATH_PARAM = ":" + KEYSPACE; + public static final String TABLE_PATH_PARAM = ":" + TABLE; public static final String SNAPSHOT_PATH_PARAM = ":snapshot"; public static final String COMPONENT_PATH_PARAM = ":component"; public static final String INDEX_PATH_PARAM = ":index"; diff --git a/conf/sidecar.yaml b/conf/sidecar.yaml index 5ebda7a9a..2fe4da000 100644 --- a/conf/sidecar.yaml +++ b/conf/sidecar.yaml @@ -174,6 +174,13 @@ access_control: # # other options are, io.vertx.ext.auth.mtls.impl.SpiffeIdentityExtractor. certificate_identity_extractor: org.apache.cassandra.sidecar.acl.authentication.CassandraIdentityExtractor + authorizer: + # AuthorizationProvider provides authorizations an authenticated user holds. + # + # org.apache.cassandra.sidecar.acl.authorization.AllowAllAuthorizationProvider marks all requests as authorized. + # Other options are org.apache.cassandra.sidecar.acl.authorization.RoleBaseAuthorizationProvider, it validates + # role associated with authenticated user has permission for resource it accesses. + - class_name: org.apache.cassandra.sidecar.acl.authorization.AllowAllAuthorizationProvider # Identities that are authenticated and authorized. admin_identities: # - spiffe://authorized/admin/identities diff --git a/server/src/main/java/org/apache/cassandra/sidecar/exceptions/SchemaUnavailableException.java b/server-common/src/main/java/org/apache/cassandra/sidecar/common/server/exceptions/SchemaUnavailableException.java similarity index 69% rename from server/src/main/java/org/apache/cassandra/sidecar/exceptions/SchemaUnavailableException.java rename to server-common/src/main/java/org/apache/cassandra/sidecar/common/server/exceptions/SchemaUnavailableException.java index 0ea9ed91a..1efb55cbe 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/exceptions/SchemaUnavailableException.java +++ b/server-common/src/main/java/org/apache/cassandra/sidecar/common/server/exceptions/SchemaUnavailableException.java @@ -16,21 +16,21 @@ * limitations under the License. */ -package org.apache.cassandra.sidecar.exceptions; +package org.apache.cassandra.sidecar.common.server.exceptions; /** - * Exception thrown when {@link org.apache.cassandra.sidecar.db.schema.TableSchema} is not prepared or expected - * operations are unavailable. + * Exception thrown when {@link org.apache.cassandra.sidecar.db.schema.TableSchema} does not exist. + * For instance, the connected Cassandra no longer has such table */ public class SchemaUnavailableException extends RuntimeException { - public SchemaUnavailableException(String message) + public SchemaUnavailableException(String keyspace, String table) { - super(message); + super(makeErrorMessage(keyspace, table)); } - public SchemaUnavailableException(String message, Throwable cause) + private static String makeErrorMessage(String keyspace, String table) { - super(message, cause); + return "Table " + keyspace + '/' + table + " does not exist"; } } diff --git a/server-common/src/main/java/org/apache/cassandra/sidecar/db/schema/AbstractSchema.java b/server-common/src/main/java/org/apache/cassandra/sidecar/db/schema/AbstractSchema.java index 7b961d318..400d54fa4 100644 --- a/server-common/src/main/java/org/apache/cassandra/sidecar/db/schema/AbstractSchema.java +++ b/server-common/src/main/java/org/apache/cassandra/sidecar/db/schema/AbstractSchema.java @@ -28,6 +28,7 @@ import com.datastax.driver.core.PreparedStatement; import com.datastax.driver.core.ResultSet; import com.datastax.driver.core.Session; +import org.apache.cassandra.sidecar.common.server.exceptions.SchemaUnavailableException; import org.apache.cassandra.sidecar.exceptions.SidecarSchemaModificationException; import org.jetbrains.annotations.NotNull; @@ -45,6 +46,17 @@ public synchronized boolean initialize(@NotNull Session session, @NotNull Predic return initialized; } + public synchronized void reset() + { + initialized = false; + unprepareStatements(); + } + + protected void ensureSchemaAvailable() throws SchemaUnavailableException + { + // no-op + } + protected PreparedStatement prepare(PreparedStatement cached, Session session, String cqlLiteral) { return cached == null ? session.prepare(cqlLiteral).setConsistencyLevel(ConsistencyLevel.LOCAL_QUORUM) : cached; @@ -80,6 +92,7 @@ protected boolean initializeInternal(@NotNull Session session, } prepareStatements(session); + logger.info("{} is initialized!", this.getClass().getSimpleName()); return true; } @@ -95,6 +108,8 @@ protected boolean initializeInternal(@NotNull Session session, */ protected abstract void prepareStatements(@NotNull Session session); + protected abstract void unprepareStatements(); + /** * @param metadata the cluster metadata * @return {@code true} if the schema already exists in the database, {@code false} otherwise diff --git a/server/src/main/java/org/apache/cassandra/sidecar/acl/AuthCache.java b/server/src/main/java/org/apache/cassandra/sidecar/acl/AuthCache.java index bfd630160..d5d74975c 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/acl/AuthCache.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/acl/AuthCache.java @@ -31,6 +31,7 @@ import com.github.benmanes.caffeine.cache.LoadingCache; import io.vertx.core.Vertx; import io.vertx.core.eventbus.EventBus; +import org.apache.cassandra.sidecar.common.server.exceptions.SchemaUnavailableException; import org.apache.cassandra.sidecar.concurrent.ExecutorPools; import org.apache.cassandra.sidecar.concurrent.TaskExecutorPool; import org.apache.cassandra.sidecar.config.CacheConfiguration; @@ -164,6 +165,10 @@ protected void warmUp(int availableRetries) { cache.putAll(bulkLoadFunction.get()); } + catch (SchemaUnavailableException sue) + { + LOGGER.warn("Auth schema is unavailable. Skip warming up cache", sue); + } catch (Exception e) { LOGGER.warn("Unexpected error encountered during pre-warming of cache={} ", name, e); diff --git a/server/src/main/java/org/apache/cassandra/sidecar/acl/IdentityToRoleCache.java b/server/src/main/java/org/apache/cassandra/sidecar/acl/IdentityToRoleCache.java index 1ede4188f..1c07600a9 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/acl/IdentityToRoleCache.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/acl/IdentityToRoleCache.java @@ -21,10 +21,10 @@ import com.google.inject.Inject; import com.google.inject.Singleton; import io.vertx.core.Vertx; +import org.apache.cassandra.sidecar.common.server.exceptions.SchemaUnavailableException; import org.apache.cassandra.sidecar.concurrent.ExecutorPools; import org.apache.cassandra.sidecar.config.SidecarConfiguration; import org.apache.cassandra.sidecar.db.SystemAuthDatabaseAccessor; -import org.apache.cassandra.sidecar.exceptions.SchemaUnavailableException; /** * Caches entries from system_auth.identity_to_role table. The table maps valid certificate identities to Cassandra diff --git a/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/Action.java b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/Action.java new file mode 100644 index 000000000..08e06083e --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/Action.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.acl.authorization; + +import io.vertx.ext.auth.authorization.Authorization; + +/** + * Represents an action that can be granted to a user on a resource or across resources. + */ +public interface Action +{ + /** + * @return {@link Authorization}. + */ + default Authorization toAuthorization() + { + return toAuthorization(null); + } + + /** + * @return {@link Authorization} created for a resource + */ + Authorization toAuthorization(String resource); +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/AdminIdentityResolver.java b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/AdminIdentityResolver.java new file mode 100644 index 000000000..91f7a15de --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/AdminIdentityResolver.java @@ -0,0 +1,60 @@ +/* + * 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.acl.authorization; + +import java.util.Set; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.vertx.ext.web.handler.HttpException; +import org.apache.cassandra.sidecar.acl.IdentityToRoleCache; +import org.apache.cassandra.sidecar.config.SidecarConfiguration; + +/** + * Evaluates if provided identity is an admin identity. + */ +@Singleton +public class AdminIdentityResolver +{ + private final IdentityToRoleCache identityToRoleCache; + private final SuperUserCache superUserCache; + private final Set adminIdentities; + + @Inject + public AdminIdentityResolver(IdentityToRoleCache identityToRoleCache, + SuperUserCache superUserCache, + SidecarConfiguration sidecarConfiguration) + { + this.identityToRoleCache = identityToRoleCache; + this.superUserCache = superUserCache; + this.adminIdentities = sidecarConfiguration.accessControlConfiguration().adminIdentities(); + } + + public boolean isAdmin(String identity) + { + String role = identityToRoleCache.get(identity); + if (role == null) + { + throw new HttpException(HttpResponseStatus.FORBIDDEN.code(), "No matching Cassandra role found"); + } + // Sidecar configured and Cassandra superusers have admin privileges + return adminIdentities.contains(identity) || superUserCache.isSuperUser(role); + } +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/AllowAllAuthorization.java b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/AllowAllAuthorization.java new file mode 100644 index 000000000..2ec8655bd --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/AllowAllAuthorization.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 org.apache.cassandra.sidecar.acl.authorization; + +import io.vertx.ext.auth.authorization.Authorization; +import io.vertx.ext.auth.authorization.AuthorizationContext; + +/** + * {@code Authorization} implementation to allow access for all users regardless of their authorizations. + */ +public class AllowAllAuthorization implements Authorization +{ + public static final AllowAllAuthorization INSTANCE = new AllowAllAuthorization(); + + // use static INSTANCE + private AllowAllAuthorization() + { + } + + /** + * Marks match as true regardless of the {@link AuthorizationContext} shared + */ + @Override + public boolean match(AuthorizationContext context) + { + return true; + } + + /** + * Allows access regardless of {@link Authorization} shared. + */ + @Override + public boolean verify(Authorization authorization) + { + return true; + } +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/AllowAllAuthorizationProvider.java b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/AllowAllAuthorizationProvider.java new file mode 100644 index 000000000..d87a9980a --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/AllowAllAuthorizationProvider.java @@ -0,0 +1,65 @@ +/* + * 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.acl.authorization; + +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.AuthorizationProvider; + +/** + * {@link AuthorizationProvider} implementation to allow all requests regardless of authorizations user holds. + */ +public class AllowAllAuthorizationProvider implements AuthorizationProvider +{ + public static final AllowAllAuthorizationProvider INSTANCE = new AllowAllAuthorizationProvider(); + + // use static INSTANCE + private AllowAllAuthorizationProvider() + { + } + + /** + * @return unique id representing {@code AllowAllAuthorizationProvider} + */ + @Override + public String getId() + { + return "AllowAll"; + } + + @Override + public void getAuthorizations(User user, Handler> handler) + { + getAuthorizations(user).onComplete(handler); + } + + @Override + public Future getAuthorizations(User user) + { + if (user == null) + { + return Future.failedFuture("User cannot be null"); + } + + user.authorizations().add(getId(), AllowAllAuthorization.INSTANCE); + return Future.succeededFuture(); + } +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/AuthorizationWithAdminBypassHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/AuthorizationWithAdminBypassHandler.java new file mode 100644 index 000000000..7ff7d9330 --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/AuthorizationWithAdminBypassHandler.java @@ -0,0 +1,64 @@ +/* + * 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.acl.authorization; + +import java.util.List; + +import io.netty.handler.codec.http.HttpResponseStatus; +import io.vertx.ext.auth.authorization.Authorization; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.HttpException; +import io.vertx.ext.web.handler.impl.AuthorizationHandlerImpl; + +import static org.apache.cassandra.sidecar.utils.AuthUtils.extractIdentities; + +/** + * Verifies user has required authorizations. Allows admin identities to bypass authorization checks. + */ +public class AuthorizationWithAdminBypassHandler extends AuthorizationHandlerImpl +{ + private final AdminIdentityResolver adminIdentityResolver; + + public AuthorizationWithAdminBypassHandler(AdminIdentityResolver adminIdentityResolver, + Authorization authorization) + { + super(authorization); + this.adminIdentityResolver = adminIdentityResolver; + } + + @Override + public void handle(RoutingContext ctx) + { + List identities = extractIdentities(ctx.user()); + + if (identities.isEmpty()) + { + throw new HttpException(HttpResponseStatus.FORBIDDEN.code(), "Missing client identities"); + } + + // Admin identities bypass route specific authorization checks + if (identities.stream().anyMatch(adminIdentityResolver::isAdmin)) + { + ctx.next(); + return; + } + + super.handle(ctx); + } +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/CassandraActions.java b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/CassandraActions.java new file mode 100644 index 000000000..9482b12b1 --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/CassandraActions.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 org.apache.cassandra.sidecar.acl.authorization; + +/** + * Cassandra actions allowed. + */ +public class CassandraActions +{ + public static final Action CREATE = new StandardAction("CREATE"); + public static final Action ALTER = new StandardAction("ALTER"); + public static final Action DROP = new StandardAction("DROP"); + public static final Action SELECT = new StandardAction("SELECT"); + public static final Action MODIFY = new StandardAction("MODIFY"); + public static final Action AUTHORIZE = new StandardAction("AUTHORIZE"); + public static final Action DESCRIBE = new StandardAction("DESCRIBE"); + public static final Action EXECUTE = new StandardAction("EXECUTE"); + public static final Action UNMASK = new StandardAction("UNMASK"); + public static final Action SELECT_MASKED = new StandardAction("SELECT_MASKED"); +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/RoleAuthorizationsCache.java b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/RoleAuthorizationsCache.java new file mode 100644 index 000000000..ee7ad46e9 --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/RoleAuthorizationsCache.java @@ -0,0 +1,100 @@ +/* + * 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.acl.authorization; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import io.vertx.core.Vertx; +import io.vertx.ext.auth.authorization.Authorization; +import org.apache.cassandra.sidecar.acl.AuthCache; +import org.apache.cassandra.sidecar.concurrent.ExecutorPools; +import org.apache.cassandra.sidecar.config.SidecarConfiguration; +import org.apache.cassandra.sidecar.db.SidecarPermissionsDatabaseAccessor; +import org.apache.cassandra.sidecar.db.SystemAuthDatabaseAccessor; + +/** + * Caches role and authorizations held by it. Entries from system_auth.role_permissions table in Cassandra and + * sidecar_internal.role_permissions_v1 table are processed into authorizations and cached here. All table entries are + * stored against a unique cache key. Caching against UNIQUE_CACHE_ENTRY is done to make sure new entries in the table + * are picked up during cache refreshes. + */ +@Singleton +public class RoleAuthorizationsCache extends AuthCache>> +{ + private static final String NAME = "role_permissions_cache"; + protected static final String UNIQUE_CACHE_ENTRY = "unique_cache_entry_key"; + + @Inject + public RoleAuthorizationsCache(Vertx vertx, + ExecutorPools executorPools, + SidecarConfiguration sidecarConfiguration, + SystemAuthDatabaseAccessor systemAuthDatabaseAccessor, + SidecarPermissionsDatabaseAccessor sidecarPermissionsDatabaseAccessor) + { + super(NAME, + vertx, + executorPools, + k -> loadAuthorizations(systemAuthDatabaseAccessor, + sidecarConfiguration.serviceConfiguration().schemaKeyspaceConfiguration().isEnabled(), + sidecarPermissionsDatabaseAccessor), + () -> Collections.singletonMap(UNIQUE_CACHE_ENTRY, + loadAuthorizations(systemAuthDatabaseAccessor, + sidecarConfiguration.serviceConfiguration().schemaKeyspaceConfiguration().isEnabled(), + sidecarPermissionsDatabaseAccessor)), + sidecarConfiguration.accessControlConfiguration().permissionCacheConfiguration()); + } + + /** + * Returns a {@code Set} of {@link Authorization} a role holds. + */ + public Set getAuthorizations(String role) + { + Map> roleAuthorizations = get(UNIQUE_CACHE_ENTRY); + return roleAuthorizations != null ? roleAuthorizations.get(role) : Collections.emptySet(); + } + + private static Map> loadAuthorizations(SystemAuthDatabaseAccessor systemAuthDatabaseAccessor, + boolean isSidecarSchemaEnabled, + SidecarPermissionsDatabaseAccessor sidecarPermissionsDatabaseAccessor) + { + // when entries in cache are not found, null is returned. We can not add null in Map + Map> roleAuthorizations + = Optional.ofNullable(systemAuthDatabaseAccessor.getAllRolesAndPermissions()).orElse(Collections.emptyMap()); + + if (isSidecarSchemaEnabled) + { + Map> sidecarAuthorizations + = Optional.ofNullable(sidecarPermissionsDatabaseAccessor.getAllRolesAndPermissions()).orElse(Collections.emptyMap()); + + // merge authorizations from Cassandra and Sidecar tables + sidecarAuthorizations.forEach((role, authorizations) -> { + roleAuthorizations.merge(role, authorizations, (existingAuthorizations, newAuthorizations) -> { + existingAuthorizations.addAll(newAuthorizations); + return existingAuthorizations; + }); + }); + } + return roleAuthorizations; + } +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/RoleBasedAuthorizationProvider.java b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/RoleBasedAuthorizationProvider.java new file mode 100644 index 000000000..23c542136 --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/RoleBasedAuthorizationProvider.java @@ -0,0 +1,100 @@ +/* + * 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.acl.authorization; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import io.netty.handler.codec.http.HttpResponseStatus; +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.Authorization; +import io.vertx.ext.auth.authorization.AuthorizationProvider; +import io.vertx.ext.web.handler.HttpException; +import org.apache.cassandra.sidecar.acl.IdentityToRoleCache; + +import static org.apache.cassandra.sidecar.utils.AuthUtils.extractIdentities; + +/** + * Provides authorizations based on user's role. Extracts permissions user holds from Cassandra's + * system_auth.role_permissions table and from Sidecar's sidecar_internal.role_permissions_v1 table and sets + * them in user. + */ +public class RoleBasedAuthorizationProvider implements AuthorizationProvider +{ + private final IdentityToRoleCache identityToRoleCache; + private final RoleAuthorizationsCache roleAuthorizationsCache; + + public RoleBasedAuthorizationProvider(IdentityToRoleCache identityToRoleCache, + RoleAuthorizationsCache roleAuthorizationsCache) + { + this.identityToRoleCache = identityToRoleCache; + this.roleAuthorizationsCache = roleAuthorizationsCache; + } + + @Override + public String getId() + { + return "RoleBasedAccessControl"; + } + + @Override + public void getAuthorizations(User user, Handler> handler) + { + getAuthorizations(user).onComplete(handler); + } + + @Override + public Future getAuthorizations(User user) + { + List identities = extractIdentities(user); + + if (identities.isEmpty()) + { + return Future.failedFuture(new HttpException(HttpResponseStatus.FORBIDDEN.code(), + "Missing client identities")); + } + + Set authorizations = new HashSet<>(); + for (String identity : identities) + { + String role = identityToRoleCache.get(identity); + if (role == null) + { + continue; + } + // when entries in cache are not found, null is returned. We can not add null in user.authorizations() + authorizations.addAll(Optional.ofNullable(roleAuthorizationsCache.getAuthorizations(role)) + .orElse(Collections.emptySet())); + } + + if (authorizations.isEmpty()) + { + return Future.failedFuture(new HttpException(HttpResponseStatus.FORBIDDEN.code(), + "User does not have any permissions")); + } + user.authorizations().add(getId(), authorizations); + return Future.succeededFuture(); + } +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/SidecarActions.java b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/SidecarActions.java new file mode 100644 index 000000000..6913439b2 --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/SidecarActions.java @@ -0,0 +1,68 @@ +/* + * 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.acl.authorization; + +/** + * Sidecar actions allowed on specific targets are listed here. Majority of sidecar actions are represented in + * format :. + *

+ * Example, with CREATE:SNAPSHOT permission, CREATE action is allowed for SNAPSHOT target. Sample actions are + * CREATE, VIEW, UPDATE, DELETE, STREAM, IMPORT, UPLOAD, START, ABORT etc. + *

+ * Wildcard actions are supported with ':' wildcard parts divider and '*' wildcard token to match parts: + *

+ * - *:SNAPSHOT allows CREATE:SNAPSHOT, VIEW:SNAPSHOT and DELETE:SNAPSHOT. + * - CREATE:* allows CREATE action on all possible targets. + * - *:* allows all possible permissions for specified resource + */ +public class SidecarActions +{ + // cassandra cluster related actions + public static final Action VIEW_CLUSTER = new WildcardAction("VIEW:CLUSTER"); + + // SSTable related actions + public static final Action UPLOAD_SSTABLE = new WildcardAction("UPLOAD:SSTABLE"); + public static final Action IMPORT_SSTABLE = new WildcardAction("IMPORT:SSTABLE"); + public static final Action STREAM_SSTABLE = new WildcardAction("STREAM:SSTABLE"); + + // Upload related actions + public static final Action DELETE_UPLOAD = new WildcardAction("DELETE:UPLOAD"); + + // snapshot related actions + public static final Action CREATE_SNAPSHOT = new WildcardAction("CREATE:SNAPSHOT"); + public static final Action VIEW_SNAPSHOT = new WildcardAction("VIEW:SNAPSHOT"); + public static final Action DELETE_SNAPSHOT = new WildcardAction("DELETE:SNAPSHOT"); + + // restore related actions + public static final Action CREATE_RESTORE = new WildcardAction("CREATE:RESTORE_JOB"); + public static final Action VIEW_RESTORE = new WildcardAction("VIEW:RESTORE_JOB"); + public static final Action UPDATE_RESTORE = new WildcardAction("UPDATE:RESTORE_JOB"); + public static final Action ABORT_RESTORE = new WildcardAction("ABORT:RESTORE_JOB"); + + // cdc related actions + public static final Action STREAM_CDC = new WildcardAction("STREAM:CDC"); + public static final Action VIEW_CDC = new WildcardAction("VIEW:CDC"); + + // sidecar internal actions + public static final Action VIEW_TASKS = new WildcardAction("VIEW:TASKS"); + + // cassandra data related actions + public static final Action VIEW_SCHEMA = new WildcardAction("VIEW:SCHEMA"); + public static final Action VIEW_TOPOLOGY = new WildcardAction("VIEW:TOPOLOGY"); +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/StandardAction.java b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/StandardAction.java new file mode 100644 index 000000000..b21698a7b --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/StandardAction.java @@ -0,0 +1,55 @@ +/* + * 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.acl.authorization; + +import io.vertx.ext.auth.authorization.Authorization; +import io.vertx.ext.auth.authorization.PermissionBasedAuthorization; +import io.vertx.ext.auth.authorization.impl.PermissionBasedAuthorizationImpl; +import org.apache.cassandra.sidecar.exceptions.ConfigurationException; + +import static org.apache.cassandra.sidecar.acl.authorization.WildcardAction.WILDCARD_TOKEN; + +/** + * Standard actions need an exact match between allowed actions. + */ +public class StandardAction implements Action +{ + protected final String name; + + public StandardAction(String name) + { + if (name == null || name.isEmpty()) + { + throw new IllegalArgumentException("Action name can not be null or empty. To allow wildcard action across " + + "resources, use " + WILDCARD_TOKEN); + } + this.name = name; + } + + @Override + public Authorization toAuthorization(String resource) + { + PermissionBasedAuthorization authorization = new PermissionBasedAuthorizationImpl(name); + if (resource != null && !resource.isEmpty()) + { + authorization.setResource(resource); + } + return authorization; + } +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/SuperUserCache.java b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/SuperUserCache.java new file mode 100644 index 000000000..48c58bc94 --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/SuperUserCache.java @@ -0,0 +1,55 @@ +/* + * 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.acl.authorization; + +import java.util.Optional; + +import com.google.inject.Inject; +import io.vertx.core.Vertx; +import org.apache.cassandra.sidecar.acl.AuthCache; +import org.apache.cassandra.sidecar.concurrent.ExecutorPools; +import org.apache.cassandra.sidecar.config.SidecarConfiguration; +import org.apache.cassandra.sidecar.db.SystemAuthDatabaseAccessor; + +/** + * Caches superuser status of cassandra roles. + */ +public class SuperUserCache extends AuthCache +{ + private static final String NAME = "super_user_cache"; + + @Inject + public SuperUserCache(Vertx vertx, + ExecutorPools executorPools, + SidecarConfiguration sidecarConfiguration, + SystemAuthDatabaseAccessor systemAuthDatabaseAccessor) + { + super(NAME, + vertx, + executorPools, + systemAuthDatabaseAccessor::isSuperUser, + systemAuthDatabaseAccessor::getRoles, + sidecarConfiguration.accessControlConfiguration().permissionCacheConfiguration()); + } + + public boolean isSuperUser(String role) + { + return Optional.ofNullable(get(role)).orElse(false); + } +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/VariableAwareResource.java b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/VariableAwareResource.java new file mode 100644 index 000000000..f12cb2536 --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/VariableAwareResource.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.acl.authorization; + +import static org.apache.cassandra.sidecar.common.ApiEndpointsV1.KEYSPACE; +import static org.apache.cassandra.sidecar.common.ApiEndpointsV1.TABLE; + +/** + * Resources sidecar can expect permissions for. This list is not exhaustive. + */ +public enum VariableAwareResource +{ + CLUSTER("cluster"), + SIDECAR("sidecar"), + DATA_WITH_KEYSPACE(String.format("data/{%s}", KEYSPACE)), + DATA_WITH_KEYSPACE_TABLE(String.format("data/{%s}/{%s}", KEYSPACE, TABLE)); + + private final String resource; + + VariableAwareResource(String resource) + { + this.resource = resource; + } + + public String resource() + { + return resource; + } +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/WildcardAction.java b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/WildcardAction.java new file mode 100644 index 000000000..2ee9fbfa7 --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/WildcardAction.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.acl.authorization; + +import java.util.Arrays; + +import io.vertx.ext.auth.authorization.Authorization; +import io.vertx.ext.auth.authorization.WildcardPermissionBasedAuthorization; +import io.vertx.ext.auth.authorization.impl.WildcardPermissionBasedAuthorizationImpl; + +/** + * Wildcard actions allow grouping allowed actions + */ +public class WildcardAction extends StandardAction +{ + public static final String WILDCARD_TOKEN = "*"; + public static final String WILDCARD_PART_DIVIDER_TOKEN = ":"; + + public WildcardAction(String name) + { + super(name); + if (!name.contains(WILDCARD_TOKEN) && !name.contains(WILDCARD_PART_DIVIDER_TOKEN)) + { + throw new IllegalArgumentException("Wildcard actions must either have wildcard token " + WILDCARD_TOKEN + + " or must have wildcard parts"); + } + validate(name); + } + + private void validate(String name) + { + String[] wildcardParts = name.split(WILDCARD_PART_DIVIDER_TOKEN); + boolean hasEmptyParts = Arrays.stream(wildcardParts).anyMatch(String::isEmpty); + if (wildcardParts.length == 0 || hasEmptyParts) + { + throw new IllegalArgumentException("Wildcard action parts can not be empty"); + } + } + + @Override + public Authorization toAuthorization(String resource) + { + WildcardPermissionBasedAuthorization authorization = new WildcardPermissionBasedAuthorizationImpl(name); + if (resource != null && !resource.isEmpty()) + { + authorization.setResource(resource); + } + return authorization; + } +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/cluster/CQLSessionProviderImpl.java b/server/src/main/java/org/apache/cassandra/sidecar/cluster/CQLSessionProviderImpl.java index 3ec6b8497..f98465198 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/cluster/CQLSessionProviderImpl.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/cluster/CQLSessionProviderImpl.java @@ -51,6 +51,7 @@ import com.datastax.driver.core.policies.ReconnectionPolicy; import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.SslContextBuilder; +import io.vertx.core.Vertx; import org.apache.cassandra.sidecar.cluster.driver.SidecarLoadBalancingPolicy; import org.apache.cassandra.sidecar.common.server.CQLSessionProvider; import org.apache.cassandra.sidecar.common.server.utils.DriverUtils; @@ -62,6 +63,8 @@ import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.VisibleForTesting; +import static org.apache.cassandra.sidecar.server.SidecarServerEvents.ON_CASSANDRA_DRIVER_CLOSED; + /** * Provides connections to the local Cassandra cluster as defined in the Configuration. Currently, it only supports * returning the local connection. @@ -69,6 +72,7 @@ public class CQLSessionProviderImpl implements CQLSessionProvider { private static final Logger logger = LoggerFactory.getLogger(CQLSessionProviderImpl.class); + private final Vertx vertx; private final List contactPoints; private final int numAdditionalConnections; private final String localDc; @@ -83,14 +87,16 @@ public class CQLSessionProviderImpl implements CQLSessionProvider private volatile Session session; @VisibleForTesting - public CQLSessionProviderImpl(List contactPoints, + public CQLSessionProviderImpl(Vertx vertx, + List contactPoints, List localInstances, int healthCheckFrequencyMillis, String localDc, int numAdditionalConnections, NettyOptions options) { - this(contactPoints, + this(vertx, + contactPoints, localInstances, healthCheckFrequencyMillis, localDc, @@ -102,7 +108,8 @@ public CQLSessionProviderImpl(List contactPoints, } @VisibleForTesting - public CQLSessionProviderImpl(List contactPoints, + public CQLSessionProviderImpl(Vertx vertx, + List contactPoints, List localInstances, int healthCheckFrequencyMillis, String localDc, @@ -112,6 +119,7 @@ public CQLSessionProviderImpl(List contactPoints, SslConfiguration sslConfiguration, NettyOptions options) { + this.vertx = vertx; this.contactPoints = contactPoints; this.localInstances = localInstances; this.localDc = localDc; @@ -124,10 +132,12 @@ public CQLSessionProviderImpl(List contactPoints, this.driverUtils = new DriverUtils(); } - public CQLSessionProviderImpl(SidecarConfiguration configuration, + public CQLSessionProviderImpl(Vertx vertx, + SidecarConfiguration configuration, NettyOptions options, DriverUtils driverUtils) { + this.vertx = vertx; this.driverUtils = driverUtils; DriverConfiguration driverConfiguration = configuration.driverConfiguration(); this.contactPoints = driverConfiguration.contactPoints(); @@ -258,6 +268,7 @@ public void close() try { localSession.getCluster().closeAsync().get(1, TimeUnit.MINUTES); + vertx.eventBus().publish(ON_CASSANDRA_DRIVER_CLOSED.address(), null); } catch (InterruptedException e) { diff --git a/server/src/main/java/org/apache/cassandra/sidecar/config/AccessControlConfiguration.java b/server/src/main/java/org/apache/cassandra/sidecar/config/AccessControlConfiguration.java index 6850a825d..8456d700e 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/config/AccessControlConfiguration.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/config/AccessControlConfiguration.java @@ -37,6 +37,11 @@ public interface AccessControlConfiguration */ List authenticatorsConfiguration(); + /** + * @return configuration needed for setting up authorizer in Sidecar + */ + ParameterizedClassConfiguration authorizerConfiguration(); + /** * @return A {@code Set} of administrative identities that are always authenticated and authorized */ diff --git a/server/src/main/java/org/apache/cassandra/sidecar/config/yaml/AccessControlConfigurationImpl.java b/server/src/main/java/org/apache/cassandra/sidecar/config/yaml/AccessControlConfigurationImpl.java index a578396d6..b7c877c9c 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/config/yaml/AccessControlConfigurationImpl.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/config/yaml/AccessControlConfigurationImpl.java @@ -35,6 +35,7 @@ public class AccessControlConfigurationImpl implements AccessControlConfiguratio { private static final boolean DEFAULT_ENABLED = false; private static final List DEFAULT_AUTHENTICATORS_CONFIGURATION = Collections.emptyList(); + private static final ParameterizedClassConfiguration DEFAULT_AUTHORIZER_CONFIGURATION = null; private static final Set DEFAULT_ADMIN_IDENTITIES = Collections.emptySet(); private static final CacheConfiguration DEFAULT_PERMISSION_CACHE_CONFIGURATION = new CacheConfigurationImpl(TimeUnit.HOURS.toMillis(2), 1_000); @@ -44,6 +45,9 @@ public class AccessControlConfigurationImpl implements AccessControlConfiguratio @JsonProperty(value = "authenticators") protected final List authenticatorsConfiguration; + @JsonProperty(value = "authorizer") + protected final ParameterizedClassConfiguration authorizerConfiguration; + @JsonProperty(value = "admin_identities") protected final Set adminIdentities; @@ -52,16 +56,19 @@ public class AccessControlConfigurationImpl implements AccessControlConfiguratio public AccessControlConfigurationImpl() { - this(DEFAULT_ENABLED, DEFAULT_AUTHENTICATORS_CONFIGURATION, DEFAULT_ADMIN_IDENTITIES, DEFAULT_PERMISSION_CACHE_CONFIGURATION); + this(DEFAULT_ENABLED, DEFAULT_AUTHENTICATORS_CONFIGURATION, DEFAULT_AUTHORIZER_CONFIGURATION, + DEFAULT_ADMIN_IDENTITIES, DEFAULT_PERMISSION_CACHE_CONFIGURATION); } public AccessControlConfigurationImpl(boolean enabled, List authenticatorsConfiguration, + ParameterizedClassConfiguration authorizerConfiguration, Set adminIdentities, CacheConfiguration permissionCacheConfiguration) { this.enabled = enabled; this.authenticatorsConfiguration = authenticatorsConfiguration; + this.authorizerConfiguration = authorizerConfiguration; this.adminIdentities = adminIdentities; this.permissionCacheConfiguration = permissionCacheConfiguration; } @@ -86,6 +93,16 @@ public List authenticatorsConfiguration() return authenticatorsConfiguration; } + /** + * {@inheritDoc} + */ + @Override + @JsonProperty(value = "authorizer") + public ParameterizedClassConfiguration authorizerConfiguration() + { + return authorizerConfiguration; + } + /** * {@inheritDoc} */ diff --git a/server/src/main/java/org/apache/cassandra/sidecar/config/yaml/CacheConfigurationImpl.java b/server/src/main/java/org/apache/cassandra/sidecar/config/yaml/CacheConfigurationImpl.java index 356e55f5b..626f2d974 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/config/yaml/CacheConfigurationImpl.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/config/yaml/CacheConfigurationImpl.java @@ -55,7 +55,11 @@ public CacheConfigurationImpl(long expireAfterAccessMillis, long maximumSize) this(expireAfterAccessMillis, maximumSize, true, 5, 1000); } - public CacheConfigurationImpl(long expireAfterAccessMillis, long maximumSize, boolean enabled, int warmupRetries, long warmupRetryIntervalMillis) + public CacheConfigurationImpl(long expireAfterAccessMillis, + long maximumSize, + boolean enabled, + int warmupRetries, + long warmupRetryIntervalMillis) { this.expireAfterAccessMillis = expireAfterAccessMillis; this.maximumSize = maximumSize; diff --git a/server/src/main/java/org/apache/cassandra/sidecar/db/SidecarPermissionsDatabaseAccessor.java b/server/src/main/java/org/apache/cassandra/sidecar/db/SidecarPermissionsDatabaseAccessor.java new file mode 100644 index 000000000..227486aee --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/db/SidecarPermissionsDatabaseAccessor.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.sidecar.db; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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.Authorization; +import org.apache.cassandra.sidecar.common.server.CQLSessionProvider; +import org.apache.cassandra.sidecar.db.schema.SidecarRolePermissionsSchema; + +import static org.apache.cassandra.sidecar.utils.AuthUtils.actionFromName; + +/** + * {@link SidecarPermissionsDatabaseAccessor} is an accessor for role_permissions_v1 table under sidecar_internal + * keyspace. Custom sidecar specific permissions are stored in this table. + */ +@Singleton +public class SidecarPermissionsDatabaseAccessor extends DatabaseAccessor +{ + private static final Logger logger = LoggerFactory.getLogger(SidecarPermissionsDatabaseAccessor.class); + + @Inject + protected SidecarPermissionsDatabaseAccessor(SidecarRolePermissionsSchema tableSchema, + CQLSessionProvider sessionProvider) + { + super(tableSchema, sessionProvider); + } + + /** + * Queries Sidecar for all rows in sidecar_internal.role_permissions_v1 table. This table maps permissions of a + * role into {@link Authorization} and returns a {@code Map} of user role to authorizations. + * + * @return - {@code Map} contains role and granted authorizations + */ + public Map> getAllRolesAndPermissions() + { + BoundStatement statement = tableSchema.getAllRolesAndPermissions().bind(); + ResultSet result = execute(statement); + Map> roleAuthorizations = new HashMap<>(); + for (Row row : result) + { + String role = row.getString("role"); + String resource = row.getString("resource"); + Set permissions = row.getSet("permissions", String.class); + Set authorizations = new HashSet<>(); + for (String permission : permissions) + { + try + { + authorizations.add(actionFromName(permission).toAuthorization(resource)); + } + catch (Exception e) + { + logger.error("Error reading sidecar permission {} for resource {} for role {}, e", + permission, resource, role, e); + } + } + if (!authorizations.isEmpty()) + { + roleAuthorizations.computeIfAbsent(role, k -> new HashSet<>(authorizations)).addAll(authorizations); + } + } + return roleAuthorizations; + } +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/db/SystemAuthDatabaseAccessor.java b/server/src/main/java/org/apache/cassandra/sidecar/db/SystemAuthDatabaseAccessor.java index dfbb395ae..709b90d87 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/db/SystemAuthDatabaseAccessor.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/db/SystemAuthDatabaseAccessor.java @@ -19,16 +19,20 @@ package org.apache.cassandra.sidecar.db; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; 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.Authorization; +import io.vertx.ext.auth.authorization.impl.PermissionBasedAuthorizationImpl; import org.apache.cassandra.sidecar.common.server.CQLSessionProvider; import org.apache.cassandra.sidecar.db.schema.SystemAuthSchema; -import org.apache.cassandra.sidecar.exceptions.SchemaUnavailableException; /** * Database Accessor that queries cassandra to get information maintained under system_auth keyspace. @@ -51,9 +55,7 @@ public SystemAuthDatabaseAccessor(SystemAuthSchema systemAuthSchema, */ public String findRoleFromIdentity(String identity) { - ensureIdentityToRoleTableAccess(); - BoundStatement statement = tableSchema.selectRoleFromIdentity() - .bind(identity); + BoundStatement statement = tableSchema.selectRoleFromIdentity().bind(identity); ResultSet result = execute(statement); Row row = result.one(); return row != null ? row.getString("role") : null; @@ -66,9 +68,7 @@ public String findRoleFromIdentity(String identity) */ public Map findAllIdentityToRoles() { - ensureIdentityToRoleTableAccess(); BoundStatement statement = tableSchema.getAllRolesAndIdentities().bind(); - ResultSet resultSet = execute(statement); Map results = new HashMap<>(); for (Row row : resultSet) @@ -78,11 +78,59 @@ public Map findAllIdentityToRoles() return results; } - private void ensureIdentityToRoleTableAccess() + /** + * Queries Cassandra for all rows in system_auth.role_permissions table. Maps permissions of a role into + * {@link Authorization} and returns a {@code Map} of cassandra role to authorizations + * + * @return - {@code Map} contains role and granted authorizations + */ + public Map> getAllRolesAndPermissions() + { + BoundStatement statement = tableSchema.getAllRolesAndPermissions().bind(); + ResultSet result = execute(statement); + Map> roleAuthorizations = new HashMap<>(); + for (Row row : result) + { + String role = row.getString("role"); + String resource = row.getString("resource"); + Set authorizations = row.getSet("permissions", String.class) + .stream() + .map(permission -> new PermissionBasedAuthorizationImpl(permission) + .setResource(resource)) + .collect(Collectors.toSet()); + roleAuthorizations.computeIfAbsent(role, k -> new HashSet<>(authorizations)).addAll(authorizations); + } + return roleAuthorizations; + } + + /** + * Queries Cassandra for superuser status of a given role. + * + * @param role role in Cassandra + * @return {@code true} if given role is a superuser, {@code false} otherwise + */ + public boolean isSuperUser(String role) + { + BoundStatement statement = tableSchema.getGetSuperUserStatus().bind(role); + ResultSet result = execute(statement); + Row row = result.one(); + return row != null && row.getBool("is_superuser"); + } + + /** + * Queries Cassandra for all roles. + * + * @return - {@code Map} containing role, and their superuser status + */ + public Map getRoles() { - if (tableSchema.selectRoleFromIdentity() == null || tableSchema.getAllRolesAndIdentities() == null) + BoundStatement statement = tableSchema.getRoles().bind(); + ResultSet result = execute(statement); + Map roles = new HashMap<>(); + for (Row row : result) { - throw new SchemaUnavailableException("SystemAuthSchema was not prepared, values cannot be retrieved from table"); + roles.put(row.getString("role"), row.getBool("is_superuser")); } + return roles; } } diff --git a/server/src/main/java/org/apache/cassandra/sidecar/db/schema/RestoreJobsSchema.java b/server/src/main/java/org/apache/cassandra/sidecar/db/schema/RestoreJobsSchema.java index 506cfe42f..27ad0d1be 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/db/schema/RestoreJobsSchema.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/db/schema/RestoreJobsSchema.java @@ -71,6 +71,19 @@ protected void prepareStatements(@NotNull Session session) findAllByCreatedAt = prepare(findAllByCreatedAt, session, CqlLiterals.findAllByCreatedAt(keyspaceConfig)); } + @Override + protected void unprepareStatements() + { + insertJob = null; + updateBlobSecrets = null; + updateStatus = null; + updateJobAgent = null; + updateExpireAt = null; + updateSliceCount = null; + selectJob = null; + findAllByCreatedAt = null; + } + @Override protected String tableName() { diff --git a/server/src/main/java/org/apache/cassandra/sidecar/db/schema/RestoreRangesSchema.java b/server/src/main/java/org/apache/cassandra/sidecar/db/schema/RestoreRangesSchema.java index 135df1b1f..f098e2eab 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/db/schema/RestoreRangesSchema.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/db/schema/RestoreRangesSchema.java @@ -59,6 +59,14 @@ protected void prepareStatements(@NotNull Session session) update = prepare(update, session, CqlLiterals.update(keyspaceConfig)); } + @Override + protected void unprepareStatements() + { + insert = null; + findAll = null; + update = null; + } + @Override protected String tableName() { diff --git a/server/src/main/java/org/apache/cassandra/sidecar/db/schema/RestoreSlicesSchema.java b/server/src/main/java/org/apache/cassandra/sidecar/db/schema/RestoreSlicesSchema.java index 49e7c24b1..f89d76458 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/db/schema/RestoreSlicesSchema.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/db/schema/RestoreSlicesSchema.java @@ -59,6 +59,13 @@ protected void prepareStatements(@NotNull Session session) findAllByTokenRange = prepare(findAllByTokenRange, session, CqlLiterals.findAllByTokenRange(keyspaceConfig)); } + @Override + protected void unprepareStatements() + { + insertSlice = null; + findAllByTokenRange = null; + } + @Override protected String tableName() { diff --git a/server/src/main/java/org/apache/cassandra/sidecar/db/schema/SidecarInternalKeyspace.java b/server/src/main/java/org/apache/cassandra/sidecar/db/schema/SidecarInternalKeyspace.java index 01fb01ea4..fe22bc1a5 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/db/schema/SidecarInternalKeyspace.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/db/schema/SidecarInternalKeyspace.java @@ -56,11 +56,26 @@ public void registerTableSchema(TableSchema schema) tableSchemas.add(schema); } + public synchronized void reset() + { + // reset itself and all the table schemas it holds + super.reset(); + for (AbstractSchema schema : tableSchemas) + { + schema.reset(); + } + } + @Override protected void prepareStatements(@NotNull Session session) { } + @Override + protected void unprepareStatements() + { + } + @Override protected boolean exists(@NotNull Metadata metadata) { diff --git a/server/src/main/java/org/apache/cassandra/sidecar/db/schema/SidecarLeaseSchema.java b/server/src/main/java/org/apache/cassandra/sidecar/db/schema/SidecarLeaseSchema.java index 361bfb624..6982192e5 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/db/schema/SidecarLeaseSchema.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/db/schema/SidecarLeaseSchema.java @@ -89,6 +89,12 @@ public void prepareStatements(@NotNull Session session) keyspaceName(), tableName(), keyspaceConfig.leaseSchemaTTLSeconds())); } + protected void unprepareStatements() + { + claimLease = null; + extendLease = null; + } + /** * {@inheritDoc} */ diff --git a/server/src/main/java/org/apache/cassandra/sidecar/db/schema/SidecarRolePermissionsSchema.java b/server/src/main/java/org/apache/cassandra/sidecar/db/schema/SidecarRolePermissionsSchema.java new file mode 100644 index 000000000..bd80de8d8 --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/db/schema/SidecarRolePermissionsSchema.java @@ -0,0 +1,95 @@ +/* + * 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.db.schema; + +import com.datastax.driver.core.PreparedStatement; +import com.datastax.driver.core.Session; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import org.apache.cassandra.sidecar.config.SchemaKeyspaceConfiguration; +import org.apache.cassandra.sidecar.config.SidecarConfiguration; +import org.jetbrains.annotations.NotNull; + +/** + * sidecar_internal.role_permissions_v1 table holds custom sidecar permissions that are not stored in Cassandra. + * Permissions are stored against resource. + */ +@Singleton +public class SidecarRolePermissionsSchema extends TableSchema +{ + private static final String ROLE_PERMISSIONS_TABLE = "role_permissions_v1"; + + private final SchemaKeyspaceConfiguration keyspaceConfig; + + private PreparedStatement getAllRolesAndPermissions; + + @Inject + public SidecarRolePermissionsSchema(SidecarConfiguration sidecarConfiguration) + { + this.keyspaceConfig = sidecarConfiguration.serviceConfiguration().schemaKeyspaceConfiguration(); + } + + @Override + protected String tableName() + { + return ROLE_PERMISSIONS_TABLE; + } + + @Override + protected String keyspaceName() + { + return keyspaceConfig.keyspace(); + } + + @Override + protected void prepareStatements(@NotNull Session session) + { + getAllRolesAndPermissions = prepare(getAllRolesAndPermissions, session, CqlLiterals.getAllRolesAndPermissions(keyspaceConfig)); + } + + @Override + protected void unprepareStatements() + { + this.getAllRolesAndPermissions = null; + } + + @Override + protected String createSchemaStatement() + { + return String.format("CREATE TABLE IF NOT EXISTS %s.%s (" + + "role text," + + "resource text," + + "permissions set," + + "PRIMARY KEY(role, resource))", + keyspaceConfig.keyspace(), ROLE_PERMISSIONS_TABLE); + } + + public PreparedStatement getAllRolesAndPermissions() + { + return getAllRolesAndPermissions; + } + + private static class CqlLiterals + { + static String getAllRolesAndPermissions(SchemaKeyspaceConfiguration config) + { + return String.format("SELECT * FROM %s.%s", config.keyspace(), ROLE_PERMISSIONS_TABLE); + } + } +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/db/schema/SidecarSchema.java b/server/src/main/java/org/apache/cassandra/sidecar/db/schema/SidecarSchema.java index 1ce41337b..a03bf7a48 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/db/schema/SidecarSchema.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/db/schema/SidecarSchema.java @@ -37,6 +37,7 @@ import org.apache.cassandra.sidecar.metrics.SchemaMetrics; import static org.apache.cassandra.sidecar.server.SidecarServerEvents.ON_CASSANDRA_CQL_READY; +import static org.apache.cassandra.sidecar.server.SidecarServerEvents.ON_CASSANDRA_DRIVER_CLOSED; import static org.apache.cassandra.sidecar.server.SidecarServerEvents.ON_SERVER_STOP; import static org.apache.cassandra.sidecar.server.SidecarServerEvents.ON_SIDECAR_SCHEMA_INITIALIZED; @@ -52,7 +53,7 @@ public class SidecarSchema private final ExecutorPools executorPools; private final SchemaKeyspaceConfiguration schemaKeyspaceConfiguration; private final SidecarInternalKeyspace sidecarInternalKeyspace; - private final AtomicLong initializationTimerId = new AtomicLong(-1L); + private final AtomicLong initializationTimerId = new AtomicLong(Long.MIN_VALUE); private final CQLSessionProvider cqlSessionProvider; private final SchemaMetrics metrics; private final ClusterLease clusterLease; @@ -74,22 +75,21 @@ public SidecarSchema(Vertx vertx, this.cqlSessionProvider = cqlSessionProvider; this.metrics = metrics; this.clusterLease = clusterLease; - if (this.schemaKeyspaceConfiguration.isEnabled()) - { - configureSidecarServerEventListeners(); - } - else + configureSidecarServerEventListenersMaybe(); + } + + private void configureSidecarServerEventListenersMaybe() + { + if (!this.schemaKeyspaceConfiguration.isEnabled()) { LOGGER.info("Sidecar schema is disabled!"); + return; } - } - private void configureSidecarServerEventListeners() - { EventBus eventBus = vertx.eventBus(); - eventBus.localConsumer(ON_CASSANDRA_CQL_READY.address(), message -> startSidecarSchemaInitializer()); - eventBus.localConsumer(ON_SERVER_STOP.address(), message -> cancelTimer(initializationTimerId.get())); + eventBus.localConsumer(ON_SERVER_STOP.address(), message -> reset()); + eventBus.localConsumer(ON_CASSANDRA_DRIVER_CLOSED.address(), message -> reset()); } @VisibleForTesting @@ -99,7 +99,7 @@ public void startSidecarSchemaInitializer() return; // schedule one initializer exactly - if (!initializationTimerId.compareAndSet(-1L, 0L)) + if (!initializationTimerId.compareAndSet(Long.MIN_VALUE, -1)) { LOGGER.debug("Skipping starting the sidecar schema initializer because there is an initialization " + "in progress with timerId={}", initializationTimerId); @@ -125,12 +125,11 @@ public void ensureInitialized() } } - protected synchronized void initialize(long timerId) + private synchronized void initialize(long timerId) { // it should not happen since the callback is only scheduled when isEnabled == true if (!schemaKeyspaceConfiguration.isEnabled()) { - LOGGER.debug("Sidecar schema is not enabled"); return; } @@ -171,18 +170,36 @@ protected synchronized void initialize(long timerId) } } - protected synchronized void cancelTimer(long timerId) + private synchronized void reset() + { + if (!schemaKeyspaceConfiguration.isEnabled() || !isInitialized) + { + return; + } + + LOGGER.info("Resetting schema and nullify prepared statements"); + + cancelTimer(initializationTimerId.get()); + initializationTimerId.set(Long.MIN_VALUE); + sidecarInternalKeyspace.reset(); + isInitialized = false; + } + + private synchronized void cancelTimer(long timerId) { // invalid timerId; nothing to cancel if (timerId < 0) { return; } - initializationTimerId.compareAndSet(timerId, -1L); - executorPools.internal().cancelTimer(timerId); + + if (initializationTimerId.compareAndSet(timerId, -1L)) + { + executorPools.internal().cancelTimer(timerId); + } } - protected void reportSidecarSchemaInitialized() + private void reportSidecarSchemaInitialized() { vertx.eventBus().publish(ON_SIDECAR_SCHEMA_INITIALIZED.address(), "SidecarSchema initialized"); } diff --git a/server/src/main/java/org/apache/cassandra/sidecar/db/schema/SystemAuthSchema.java b/server/src/main/java/org/apache/cassandra/sidecar/db/schema/SystemAuthSchema.java index e96b21b5f..0e9f8ea07 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/db/schema/SystemAuthSchema.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/db/schema/SystemAuthSchema.java @@ -22,8 +22,8 @@ import com.datastax.driver.core.PreparedStatement; import com.datastax.driver.core.Session; import com.google.inject.Singleton; +import org.apache.cassandra.sidecar.common.server.exceptions.SchemaUnavailableException; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; /** * Schema for getting information stored in system_auth keyspace. @@ -32,8 +32,19 @@ public class SystemAuthSchema extends CassandraSystemTableSchema { private static final String IDENTITY_TO_ROLE_TABLE = "identity_to_role"; + + private static final String SELECT_ROLE_FROM_IDENTITY + = "SELECT role FROM system_auth.identity_to_role WHERE identity = ?"; + private static final String GET_ALL_ROLES_AND_IDENTITIES = "SELECT * FROM system_auth.identity_to_role"; + private static final String GET_SUPER_USER_STATUS = "SELECT * FROM system_auth.roles WHERE role = ?"; + private static final String GET_ROLES = "SELECT * FROM system_auth.roles"; + private static final String GET_ALL_ROLES_AND_PERMISSIONS = "SELECT * FROM system_auth.role_permissions"; + private PreparedStatement selectRoleFromIdentity; private PreparedStatement getAllRolesAndIdentities; + private PreparedStatement getSuperUserStatus; + private PreparedStatement getRoles; + private PreparedStatement getAllRolesAndPermissions; @Override protected String keyspaceName() @@ -44,19 +55,42 @@ protected String keyspaceName() @Override protected void prepareStatements(@NotNull Session session) { + getSuperUserStatus = prepare(getSuperUserStatus, + session, + GET_SUPER_USER_STATUS); + + getRoles = prepare(getRoles, + session, + GET_ROLES); + + getAllRolesAndPermissions = prepare(getAllRolesAndPermissions, + session, + GET_ALL_ROLES_AND_PERMISSIONS); + KeyspaceMetadata keyspaceMetadata = session.getCluster().getMetadata().getKeyspace(keyspaceName()); // identity_to_role table exists in Cassandra versions starting 5.x if (keyspaceMetadata == null || keyspaceMetadata.getTable(IDENTITY_TO_ROLE_TABLE) == null) { + logger.info("Auth table does not exist. Skip preparing. table={}/{}", keyspaceName(), IDENTITY_TO_ROLE_TABLE); return; } selectRoleFromIdentity = prepare(selectRoleFromIdentity, session, - CqlLiterals.SELECT_ROLE_FROM_IDENTITY); + SELECT_ROLE_FROM_IDENTITY); getAllRolesAndIdentities = prepare(getAllRolesAndIdentities, session, - CqlLiterals.GET_ALL_ROLES_AND_IDENTITIES); + GET_ALL_ROLES_AND_IDENTITIES); + } + + @Override + protected void unprepareStatements() + { + selectRoleFromIdentity = null; + getAllRolesAndIdentities = null; + getAllRolesAndPermissions = null; + getSuperUserStatus = null; + getRoles = null; } @Override @@ -66,21 +100,41 @@ protected String tableName() "tables in system_auth keyspace"); } - @Nullable + @NotNull public PreparedStatement selectRoleFromIdentity() { + ensureSchemaAvailable(); return selectRoleFromIdentity; } - @Nullable + @NotNull public PreparedStatement getAllRolesAndIdentities() { + ensureSchemaAvailable(); return getAllRolesAndIdentities; } - private static class CqlLiterals + public PreparedStatement getAllRolesAndPermissions() { - static final String SELECT_ROLE_FROM_IDENTITY = "SELECT role FROM system_auth.identity_to_role WHERE identity = ?"; - static final String GET_ALL_ROLES_AND_IDENTITIES = "SELECT * FROM system_auth.identity_to_role"; + return getAllRolesAndPermissions; + } + + public PreparedStatement getGetSuperUserStatus() + { + return getSuperUserStatus; + } + + public PreparedStatement getRoles() + { + return getRoles; + } + + @Override + protected void ensureSchemaAvailable() throws SchemaUnavailableException + { + if (selectRoleFromIdentity == null || getAllRolesAndIdentities == null) + { + throw new SchemaUnavailableException(keyspaceName(), IDENTITY_TO_ROLE_TABLE); + } } } diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/AccessProtected.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/AccessProtected.java new file mode 100644 index 000000000..60780065f --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/AccessProtected.java @@ -0,0 +1,34 @@ +/* + * 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.Set; + +import io.vertx.ext.auth.authorization.Authorization; + +/** + * Provides authorizations that Handler requires for access. + */ +public interface AccessProtected +{ + /** + * @return Set of authorizations required. + */ + Set requiredAuthorizations(); +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/AccessProtectedRouteBuilder.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/AccessProtectedRouteBuilder.java new file mode 100644 index 000000000..923213876 --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/AccessProtectedRouteBuilder.java @@ -0,0 +1,208 @@ +/* + * 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.List; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; + +import io.vertx.core.Handler; +import io.vertx.core.http.HttpMethod; +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.web.Route; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.BodyHandler; +import org.apache.cassandra.sidecar.acl.authorization.AdminIdentityResolver; +import org.apache.cassandra.sidecar.acl.authorization.AuthorizationWithAdminBypassHandler; +import org.apache.cassandra.sidecar.common.utils.Preconditions; +import org.apache.cassandra.sidecar.config.AccessControlConfiguration; +import org.apache.cassandra.sidecar.exceptions.ConfigurationException; + +import static org.apache.cassandra.sidecar.common.ApiEndpointsV1.KEYSPACE; +import static org.apache.cassandra.sidecar.common.ApiEndpointsV1.TABLE; + +/** + * Builder for building authorized routes + */ +public class AccessProtectedRouteBuilder +{ + private final AccessControlConfiguration accessControlConfiguration; + private final AuthorizationProvider authorizationProvider; + private final AdminIdentityResolver adminIdentityResolver; + + private Router router; + private HttpMethod method; + private String endpoint; + private boolean setBodyHandler; + private final List> handlers = new ArrayList<>(); + + public AccessProtectedRouteBuilder(AccessControlConfiguration accessControlConfiguration, + AuthorizationProvider authorizationProvider, + AdminIdentityResolver adminIdentityResolver) + { + this.accessControlConfiguration = accessControlConfiguration; + this.authorizationProvider = authorizationProvider; + this.adminIdentityResolver = adminIdentityResolver; + } + + /** + * Sets {@link Router} authorized route is built for + * + * @param router Router authorized route is built for + * @return a reference to {@link AccessProtectedRouteBuilder} for chaining + */ + public AccessProtectedRouteBuilder router(Router router) + { + this.router = router; + return this; + } + + /** + * Sets {@link HttpMethod} for route + * + * @param method HttpMethod set for route + * @return a reference to {@link AccessProtectedRouteBuilder} for chaining + */ + public AccessProtectedRouteBuilder method(HttpMethod method) + { + this.method = method; + return this; + } + + /** + * Sets path for route + * + * @param endpoint REST path for route + * @return a reference to {@link AccessProtectedRouteBuilder} for chaining + */ + public AccessProtectedRouteBuilder endpoint(String endpoint) + { + this.endpoint = endpoint; + return this; + } + + /** + * Sets if BodyHandler should be created for the route. + * + * @param setBodyHandler boolean flag indicating if route requires BodyHandler + * @return a reference to {@link AccessProtectedRouteBuilder} for chaining + */ + public AccessProtectedRouteBuilder setBodyHandler(Boolean setBodyHandler) + { + this.setBodyHandler = setBodyHandler; + return this; + } + + /** + * Adds handler to handler chain of route. Handlers are ordered, they are called in order they are set in chain. + * + * @param handler handler for route + * @return a reference to {@link AccessProtectedRouteBuilder} for chaining + */ + public AccessProtectedRouteBuilder handler(Handler handler) + { + this.handlers.add(handler); + return this; + } + + /** + * Builds an authorized route. Adds {@link io.vertx.ext.web.handler.AuthorizationHandler} at top of handler + * chain if access control is enabled. + */ + public void build() + { + Preconditions.checkArgument(router != null, "Router must be set"); + Preconditions.checkArgument(method != null, "Http method must be set"); + Preconditions.checkArgument(endpoint != null && !endpoint.isEmpty(), "Endpoint must be set"); + Preconditions.checkArgument(!handlers.isEmpty(), "Handler chain can not be empty"); + + Route route = router.route(method, endpoint); + + // BodyHandler should be at index 0 in handler chain + if (setBodyHandler) + { + route.handler(BodyHandler.create()); + } + + if (accessControlConfiguration.enabled()) + { + // authorization handler added before route specific handler chain + AuthorizationWithAdminBypassHandler authorizationHandler + = new AuthorizationWithAdminBypassHandler(adminIdentityResolver, requiredAuthorization()); + authorizationHandler.addAuthorizationProvider(authorizationProvider); + authorizationHandler.variableConsumer(routeGenericVariableConsumer()); + + route.handler(authorizationHandler); + } + handlers.forEach(route::handler); + } + + private Authorization requiredAuthorization() + { + Set requiredAuthorizations = handlers + .stream() + .filter(handler -> handler instanceof AccessProtected) + .map(handler -> (AccessProtected) handler) + .flatMap(handler -> handler.requiredAuthorizations().stream()) + .collect(Collectors.toSet()); + if (requiredAuthorizations.isEmpty()) + { + throw new ConfigurationException("Authorized route must have enforced authorizations set"); + } + AndAuthorization andAuthorization = AndAuthorization.create(); + requiredAuthorizations.forEach(andAuthorization::addAuthorization); + return andAuthorization; + } + + private BiConsumer routeGenericVariableConsumer() + { + return (routingCtx, authZContext) -> { + if (routingCtx.pathParams().containsKey(KEYSPACE)) + { + authZContext.variables().add(KEYSPACE, routingCtx.pathParam(KEYSPACE)); + } + if (routingCtx.pathParams().containsKey(TABLE)) + { + authZContext.variables().add(TABLE, routingCtx.pathParam(TABLE)); + } + }; + } + + /** + * Creates an instance of {@link AccessProtectedRouteBuilder} for building authorized route. + * + * @param accessControlConfiguration config + * @param authorizationProvider AuthorizationProvider for retrieving user authorizations + * @param adminIdentityResolver AdminIdentityResolver for identifying admin identities + * + * @return instance of {@link AccessProtectedRouteBuilder} + */ + public static AccessProtectedRouteBuilder instance(AccessControlConfiguration accessControlConfiguration, + AuthorizationProvider authorizationProvider, + AdminIdentityResolver adminIdentityResolver) + { + return new AccessProtectedRouteBuilder(accessControlConfiguration, authorizationProvider, adminIdentityResolver); + } +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/ConnectedClientStatsHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/ConnectedClientStatsHandler.java index dc59fba71..1b0023e3e 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/ConnectedClientStatsHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/ConnectedClientStatsHandler.java @@ -18,10 +18,17 @@ package org.apache.cassandra.sidecar.routes; +import java.util.Set; + +import com.google.common.collect.ImmutableSet; + import com.google.inject.Inject; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.concurrent.ExecutorPools; import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher; @@ -30,7 +37,7 @@ /** * Handler for retrieving stats for connected clients */ -public class ConnectedClientStatsHandler extends AbstractHandler +public class ConnectedClientStatsHandler extends AbstractHandler implements AccessProtected { /** * Constructs a handler with the provided {@code metadataFetcher} @@ -44,6 +51,13 @@ protected ConnectedClientStatsHandler(InstanceMetadataFetcher metadataFetcher, E super(metadataFetcher, executorPools, null); } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.CLUSTER.resource(); + return ImmutableSet.of(SidecarActions.VIEW_CLUSTER.toAuthorization(resource)); + } + /** * {@inheritDoc} */ diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/FileStreamHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/FileStreamHandler.java index 12c590400..b5d83a9b7 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/FileStreamHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/FileStreamHandler.java @@ -21,6 +21,9 @@ import java.io.FileNotFoundException; import java.io.IOException; import java.nio.file.NoSuchFileException; +import java.util.Set; + +import com.google.common.collect.ImmutableSet; import com.google.inject.Inject; import com.google.inject.Singleton; @@ -28,7 +31,10 @@ import io.vertx.core.Future; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata; import org.apache.cassandra.sidecar.common.server.utils.ThrowableUtils; import org.apache.cassandra.sidecar.concurrent.ExecutorPools; @@ -44,7 +50,7 @@ * Handler for sending out files. */ @Singleton -public class FileStreamHandler extends AbstractHandler +public class FileStreamHandler extends AbstractHandler implements AccessProtected { public static final String FILE_PATH_CONTEXT_KEY = "fileToTransfer"; private final FileStreamer fileStreamer; @@ -58,6 +64,13 @@ public FileStreamHandler(InstanceMetadataFetcher metadataFetcher, this.fileStreamer = fileStreamer; } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.DATA_WITH_KEYSPACE_TABLE.resource(); + return ImmutableSet.of(SidecarActions.STREAM_SSTABLE.toAuthorization(resource)); + } + @Override public void handleInternal(RoutingContext context, HttpServerRequest httpRequest, diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/GossipInfoHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/GossipInfoHandler.java index 2f05cc6a6..c65f766c1 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/GossipInfoHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/GossipInfoHandler.java @@ -18,12 +18,18 @@ package org.apache.cassandra.sidecar.routes; +import java.util.Set; + import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableSet; import com.google.inject.Inject; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.cluster.CassandraAdapterDelegate; import org.apache.cassandra.sidecar.common.server.ClusterMembershipOperations; import org.apache.cassandra.sidecar.common.server.utils.GossipInfoParser; @@ -34,7 +40,7 @@ /** * Handler for retrieving gossip info */ -public class GossipInfoHandler extends AbstractHandler +public class GossipInfoHandler extends AbstractHandler implements AccessProtected { /** * Constructs a handler with the provided {@code metadataFetcher} @@ -48,6 +54,13 @@ protected GossipInfoHandler(InstanceMetadataFetcher metadataFetcher, ExecutorPoo super(metadataFetcher, executorPools, null); } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.CLUSTER.resource(); + return ImmutableSet.of(SidecarActions.VIEW_CLUSTER.toAuthorization(resource)); + } + /** * {@inheritDoc} */ diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/KeyspaceRingHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/KeyspaceRingHandler.java new file mode 100644 index 000000000..5d4653467 --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/KeyspaceRingHandler.java @@ -0,0 +1,105 @@ +package org.apache.cassandra.sidecar.routes; + +import java.util.Set; + +import com.google.common.collect.ImmutableSet; + +import org.apache.commons.lang3.StringUtils; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; +import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; +import org.apache.cassandra.sidecar.cluster.CassandraAdapterDelegate; +import org.apache.cassandra.sidecar.common.server.StorageOperations; +import org.apache.cassandra.sidecar.common.server.data.Name; +import org.apache.cassandra.sidecar.concurrent.ExecutorPools; +import org.apache.cassandra.sidecar.utils.CassandraInputValidator; +import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher; + +import static org.apache.cassandra.sidecar.utils.HttpExceptions.cassandraServiceUnavailable; +import static org.apache.cassandra.sidecar.utils.HttpExceptions.wrapHttpException; + +/** + * A handler that provides ring information for a specific keyspace for the Cassandra cluster + */ +@Singleton +public class KeyspaceRingHandler extends AbstractHandler implements AccessProtected +{ + @Inject + public KeyspaceRingHandler(InstanceMetadataFetcher metadataFetcher, + CassandraInputValidator validator, + ExecutorPools executorPools) + { + super(metadataFetcher, executorPools, validator); + } + + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.DATA_WITH_KEYSPACE.resource(); + return ImmutableSet.of(SidecarActions.VIEW_CLUSTER.toAuthorization(resource)); + } + + /** + * {@inheritDoc} + */ + @Override + public void handleInternal(RoutingContext context, + HttpServerRequest httpRequest, + String host, + SocketAddress remoteAddress, + Name keyspace) + { + CassandraAdapterDelegate delegate = metadataFetcher.delegate(host); + if (delegate == null) + { + context.fail(cassandraServiceUnavailable()); + return; + } + + StorageOperations storageOperations = delegate.storageOperations(); + + if (storageOperations == null) + { + context.fail(cassandraServiceUnavailable()); + return; + } + + executorPools.service() + .executeBlocking(() -> storageOperations.ring(keyspace)) + .onSuccess(context::json) + .onFailure(cause -> processFailure(cause, context, host, remoteAddress, keyspace)); + } + + @Override + protected void processFailure(Throwable cause, + RoutingContext context, + String host, + SocketAddress remoteAddress, + Name keyspace) + { + if (cause instanceof IllegalArgumentException && + StringUtils.contains(cause.getMessage(), ", does not exist")) + { + context.fail(wrapHttpException(HttpResponseStatus.NOT_FOUND, cause.getMessage(), cause)); + return; + } + + super.processFailure(cause, context, host, remoteAddress, keyspace); + } + + /** + * {@inheritDoc} + */ + @Override + protected Name extractParamsOrThrow(RoutingContext context) + { + return keyspace(context, true); + } +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/KeyspaceSchemaHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/KeyspaceSchemaHandler.java new file mode 100644 index 000000000..8024c1dc9 --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/KeyspaceSchemaHandler.java @@ -0,0 +1,157 @@ +/* + * 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.Set; + +import com.google.common.collect.ImmutableSet; + +import com.datastax.driver.core.KeyspaceMetadata; +import com.datastax.driver.core.Metadata; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.vertx.core.Future; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; +import io.vertx.ext.auth.authorization.OrAuthorization; +import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.CassandraActions; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; +import org.apache.cassandra.sidecar.cluster.CassandraAdapterDelegate; +import org.apache.cassandra.sidecar.common.response.SchemaResponse; +import org.apache.cassandra.sidecar.common.server.data.Name; +import org.apache.cassandra.sidecar.concurrent.ExecutorPools; +import org.apache.cassandra.sidecar.utils.CassandraInputValidator; +import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher; +import org.apache.cassandra.sidecar.utils.MetadataUtils; + +import static org.apache.cassandra.sidecar.utils.HttpExceptions.cassandraServiceUnavailable; +import static org.apache.cassandra.sidecar.utils.HttpExceptions.wrapHttpException; + +/** + * The {@link KeyspaceSchemaHandler} class handles schema request for a keyspace + */ +@Singleton +public class KeyspaceSchemaHandler extends AbstractHandler implements AccessProtected +{ + /** + * Constructs a handler with the provided {@code metadataFetcher} + * + * @param metadataFetcher the interface to retrieve metadata + * @param executorPools executor pools for blocking executions + * @param validator a validator instance to validate Cassandra-specific input + */ + @Inject + protected KeyspaceSchemaHandler(InstanceMetadataFetcher metadataFetcher, ExecutorPools executorPools, + CassandraInputValidator validator) + { + super(metadataFetcher, executorPools, validator); + } + + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.DATA_WITH_KEYSPACE.resource(); + OrAuthorization or = OrAuthorization.create(); + or.addAuthorization(CassandraActions.CREATE.toAuthorization(resource)); + or.addAuthorization(CassandraActions.ALTER.toAuthorization(resource)); + or.addAuthorization(CassandraActions.DROP.toAuthorization(resource)); + or.addAuthorization(CassandraActions.DESCRIBE.toAuthorization(resource)); + or.addAuthorization(SidecarActions.VIEW_SCHEMA.toAuthorization(resource)); + return ImmutableSet.of(or); + } + + /** + * {@inheritDoc} + */ + @Override + public void handleInternal(RoutingContext context, + HttpServerRequest httpRequest, + String host, + SocketAddress remoteAddress, + Name keyspace) + { + metadata(host) + .onFailure(cause -> processFailure(cause, context, host, remoteAddress, keyspace)) + .onSuccess(metadata -> handleWithMetadata(context, keyspace, metadata)); + } + + /** + * Handles the request with the Cassandra {@link Metadata metadata}. + * + * @param context the event to handle + * @param keyspace the keyspace parsed from the request + * @param metadata the metadata on the connected cluster, including known nodes and schema definitions + */ + private void handleWithMetadata(RoutingContext context, Name keyspace, Metadata metadata) + { + if (metadata == null) + { + // set request as failed and return + logger.error("Failed to obtain metadata on the connected cluster for request '{}'", keyspace); + context.fail(cassandraServiceUnavailable()); + return; + } + + // retrieve keyspace metadata + KeyspaceMetadata ksMetadata = MetadataUtils.keyspace(metadata, keyspace); + + if (ksMetadata == null) + { + // set request as failed and return + // keyspace does not exist + String errorMessage = String.format("Keyspace '%s' does not exist.", keyspace); + context.fail(wrapHttpException(HttpResponseStatus.NOT_FOUND, errorMessage)); + return; + } + + SchemaResponse schemaResponse = new SchemaResponse(keyspace.name(), + ksMetadata.exportAsString()); + context.json(schemaResponse); + } + + /** + * Gets cluster metadata asynchronously. + * + * @param host the Cassandra instance host + * @return {@link Future} containing {@link Metadata} + */ + private Future metadata(String host) + { + return executorPools.service().executeBlocking(() -> { + CassandraAdapterDelegate delegate = metadataFetcher.delegate(host); + // metadata can block so we need to run in a blocking thread + return delegate.metadata(); + }); + } + + /** + * Parses the request parameters + * + * @param context the event to handle + * @return the keyspace parsed from the request + */ + @Override + protected Name extractParamsOrThrow(RoutingContext context) + { + return keyspace(context, true); + } +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/ListOperationalJobsHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/ListOperationalJobsHandler.java index ba7a24dc8..d4ec82145 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/ListOperationalJobsHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/ListOperationalJobsHandler.java @@ -18,11 +18,17 @@ package org.apache.cassandra.sidecar.routes; +import java.util.Set; import javax.inject.Inject; +import com.google.common.collect.ImmutableSet; + import io.vertx.core.http.HttpServerRequest; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.common.response.ListOperationalJobsResponse; import org.apache.cassandra.sidecar.common.response.OperationalJobResponse; import org.apache.cassandra.sidecar.concurrent.ExecutorPools; @@ -35,7 +41,7 @@ /** * Handler for retrieving the all the jobs running on the sidecar */ -public class ListOperationalJobsHandler extends AbstractHandler +public class ListOperationalJobsHandler extends AbstractHandler implements AccessProtected { private final OperationalJobManager jobManager; @@ -49,6 +55,13 @@ public ListOperationalJobsHandler(InstanceMetadataFetcher metadataFetcher, this.jobManager = jobManager; } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.SIDECAR.resource(); + return ImmutableSet.of(SidecarActions.VIEW_TASKS.toAuthorization(resource)); + } + @Override protected Void extractParamsOrThrow(RoutingContext context) { diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/OperationalJobHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/OperationalJobHandler.java index 162903af3..43e1a56c7 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/OperationalJobHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/OperationalJobHandler.java @@ -18,13 +18,19 @@ package org.apache.cassandra.sidecar.routes; +import java.util.Set; import java.util.UUID; import javax.inject.Inject; +import com.google.common.collect.ImmutableSet; + import io.netty.handler.codec.http.HttpResponseStatus; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.common.data.OperationalJobStatus; import org.apache.cassandra.sidecar.common.response.OperationalJobResponse; import org.apache.cassandra.sidecar.concurrent.ExecutorPools; @@ -40,7 +46,7 @@ /** * Handler for retrieving the status of async operational jobs running on the sidecar */ -public class OperationalJobHandler extends AbstractHandler +public class OperationalJobHandler extends AbstractHandler implements AccessProtected { private final OperationalJobManager jobManager; @@ -54,6 +60,13 @@ public OperationalJobHandler(InstanceMetadataFetcher metadataFetcher, this.jobManager = jobManager; } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.SIDECAR.resource(); + return ImmutableSet.of(SidecarActions.VIEW_TASKS.toAuthorization(resource)); + } + @Override protected Void extractParamsOrThrow(RoutingContext context) { diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/RingHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/RingHandler.java index 785badc1a..56fc0c13a 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/RingHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/RingHandler.java @@ -18,6 +18,9 @@ package org.apache.cassandra.sidecar.routes; +import java.util.Set; + +import com.google.common.collect.ImmutableSet; import org.apache.commons.lang3.StringUtils; import com.google.inject.Inject; @@ -25,10 +28,12 @@ import io.netty.handler.codec.http.HttpResponseStatus; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.cluster.CassandraAdapterDelegate; import org.apache.cassandra.sidecar.common.server.StorageOperations; -import org.apache.cassandra.sidecar.common.server.data.Name; import org.apache.cassandra.sidecar.concurrent.ExecutorPools; import org.apache.cassandra.sidecar.utils.CassandraInputValidator; import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher; @@ -40,7 +45,7 @@ * A handler that provides ring information for the Cassandra cluster */ @Singleton -public class RingHandler extends AbstractHandler +public class RingHandler extends AbstractHandler implements AccessProtected { @Inject public RingHandler(InstanceMetadataFetcher metadataFetcher, @@ -50,6 +55,13 @@ public RingHandler(InstanceMetadataFetcher metadataFetcher, super(metadataFetcher, executorPools, validator); } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.CLUSTER.resource(); + return ImmutableSet.of(SidecarActions.VIEW_CLUSTER.toAuthorization(resource)); + } + /** * {@inheritDoc} */ @@ -58,7 +70,7 @@ public void handleInternal(RoutingContext context, HttpServerRequest httpRequest, String host, SocketAddress remoteAddress, - Name keyspace) + Void request) { CassandraAdapterDelegate delegate = metadataFetcher.delegate(host); if (delegate == null) @@ -76,9 +88,9 @@ public void handleInternal(RoutingContext context, } executorPools.service() - .executeBlocking(() -> storageOperations.ring(keyspace)) + .executeBlocking(() -> storageOperations.ring(null)) .onSuccess(context::json) - .onFailure(cause -> processFailure(cause, context, host, remoteAddress, keyspace)); + .onFailure(cause -> processFailure(cause, context, host, remoteAddress, request)); } @Override @@ -86,7 +98,7 @@ protected void processFailure(Throwable cause, RoutingContext context, String host, SocketAddress remoteAddress, - Name keyspace) + Void request) { if (cause instanceof IllegalArgumentException && StringUtils.contains(cause.getMessage(), ", does not exist")) @@ -95,15 +107,15 @@ protected void processFailure(Throwable cause, return; } - super.processFailure(cause, context, host, remoteAddress, keyspace); + super.processFailure(cause, context, host, remoteAddress, request); } /** * {@inheritDoc} */ @Override - protected Name extractParamsOrThrow(RoutingContext context) + protected Void extractParamsOrThrow(RoutingContext context) { - return keyspace(context, false); + return null; } } diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/SchemaHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/SchemaHandler.java index 95410f3fe..3992996f9 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/SchemaHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/SchemaHandler.java @@ -17,31 +17,33 @@ */ package org.apache.cassandra.sidecar.routes; -import com.datastax.driver.core.KeyspaceMetadata; +import java.util.Set; + +import com.google.common.collect.ImmutableSet; + import com.datastax.driver.core.Metadata; import com.google.inject.Inject; import com.google.inject.Singleton; -import io.netty.handler.codec.http.HttpResponseStatus; import io.vertx.core.Future; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.cluster.CassandraAdapterDelegate; import org.apache.cassandra.sidecar.common.response.SchemaResponse; -import org.apache.cassandra.sidecar.common.server.data.Name; import org.apache.cassandra.sidecar.concurrent.ExecutorPools; import org.apache.cassandra.sidecar.utils.CassandraInputValidator; import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher; -import org.apache.cassandra.sidecar.utils.MetadataUtils; import static org.apache.cassandra.sidecar.utils.HttpExceptions.cassandraServiceUnavailable; -import static org.apache.cassandra.sidecar.utils.HttpExceptions.wrapHttpException; /** * The {@link SchemaHandler} class handles schema requests */ @Singleton -public class SchemaHandler extends AbstractHandler +public class SchemaHandler extends AbstractHandler implements AccessProtected { /** * Constructs a handler with the provided {@code metadataFetcher} @@ -57,6 +59,13 @@ protected SchemaHandler(InstanceMetadataFetcher metadataFetcher, ExecutorPools e super(metadataFetcher, executorPools, validator); } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.CLUSTER.resource(); + return ImmutableSet.of(SidecarActions.VIEW_SCHEMA.toAuthorization(resource)); + } + /** * {@inheritDoc} */ @@ -65,51 +74,30 @@ public void handleInternal(RoutingContext context, HttpServerRequest httpRequest, String host, SocketAddress remoteAddress, - Name keyspace) + Void request) { metadata(host) - .onFailure(cause -> processFailure(cause, context, host, remoteAddress, keyspace)) - .onSuccess(metadata -> handleWithMetadata(context, keyspace, metadata)); + .onFailure(cause -> processFailure(cause, context, host, remoteAddress, request)) + .onSuccess(metadata -> handleWithMetadata(context, metadata)); } /** * Handles the request with the Cassandra {@link Metadata metadata}. * * @param context the event to handle - * @param keyspace the keyspace parsed from the request * @param metadata the metadata on the connected cluster, including known nodes and schema definitions */ - private void handleWithMetadata(RoutingContext context, Name keyspace, Metadata metadata) + private void handleWithMetadata(RoutingContext context, Metadata metadata) { if (metadata == null) { // set request as failed and return - logger.error("Failed to obtain metadata on the connected cluster for request '{}'", keyspace); + logger.error("Failed to obtain metadata on the connected cluster for request"); context.fail(cassandraServiceUnavailable()); return; } - if (keyspace == null) - { - SchemaResponse schemaResponse = new SchemaResponse(metadata.exportSchemaAsString()); - context.json(schemaResponse); - return; - } - - // retrieve keyspace metadata - KeyspaceMetadata ksMetadata = MetadataUtils.keyspace(metadata, keyspace); - - if (ksMetadata == null) - { - // set request as failed and return - // keyspace does not exist - String errorMessage = String.format("Keyspace '%s' does not exist.", keyspace); - context.fail(wrapHttpException(HttpResponseStatus.NOT_FOUND, errorMessage)); - return; - } - - SchemaResponse schemaResponse = new SchemaResponse(keyspace.name(), - ksMetadata.exportAsString()); + SchemaResponse schemaResponse = new SchemaResponse(metadata.exportSchemaAsString()); context.json(schemaResponse); } @@ -129,14 +117,11 @@ private Future metadata(String host) } /** - * Parses the request parameters - * - * @param context the event to handle - * @return the keyspace parsed from the request + * {@inheritDoc} */ @Override - protected Name extractParamsOrThrow(RoutingContext context) + protected Void extractParamsOrThrow(RoutingContext context) { - return keyspace(context, false); + return null; } } diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/StreamSSTableComponentHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/StreamSSTableComponentHandler.java index 6e1cec490..a2b332bb9 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/StreamSSTableComponentHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/StreamSSTableComponentHandler.java @@ -21,15 +21,22 @@ import java.nio.file.NoSuchFileException; import java.util.List; +import java.util.Set; import javax.management.InstanceNotFoundException; +import com.google.common.collect.ImmutableSet; + import com.google.inject.Inject; import com.google.inject.Singleton; import io.netty.handler.codec.http.HttpResponseStatus; import io.vertx.core.Future; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.CassandraActions; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.cluster.CassandraAdapterDelegate; import org.apache.cassandra.sidecar.common.server.StorageOperations; import org.apache.cassandra.sidecar.common.server.TableOperations; @@ -50,7 +57,7 @@ * for the {@link FileStreamHandler} to stream the component back to the client */ @Singleton -public class StreamSSTableComponentHandler extends AbstractHandler +public class StreamSSTableComponentHandler extends AbstractHandler implements AccessProtected { private final SnapshotPathBuilder snapshotPathBuilder; @@ -64,6 +71,15 @@ public StreamSSTableComponentHandler(InstanceMetadataFetcher metadataFetcher, this.snapshotPathBuilder = snapshotPathBuilder; } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.DATA_WITH_KEYSPACE_TABLE.resource(); + Authorization stream = SidecarActions.STREAM_SSTABLE.toAuthorization(resource); + Authorization select = CassandraActions.SELECT.toAuthorization(resource); + return ImmutableSet.of(stream, select); + } + @Override public void handleInternal(RoutingContext context, HttpServerRequest httpRequest, diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/TimeSkewHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/TimeSkewHandler.java index 5f45ab52b..6be0b2524 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/TimeSkewHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/TimeSkewHandler.java @@ -18,16 +18,23 @@ package org.apache.cassandra.sidecar.routes; +import java.util.Set; + +import com.google.common.collect.ImmutableSet; + import com.google.inject.Inject; import io.vertx.core.Handler; +import io.vertx.ext.auth.authorization.Authorization; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.utils.TimeSkewInfo; /** * Provides clients information about the current time on this host * and the allowable time skew between this host and the client. */ -public class TimeSkewHandler implements Handler +public class TimeSkewHandler implements Handler, AccessProtected { private final TimeSkewInfo timeSkewInfo; @@ -42,6 +49,13 @@ protected TimeSkewHandler(TimeSkewInfo timeSkewInfo) this.timeSkewInfo = timeSkewInfo; } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.CLUSTER.resource(); + return ImmutableSet.of(SidecarActions.VIEW_CLUSTER.toAuthorization(resource)); + } + @Override public void handle(RoutingContext context) { diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/TokenRangeReplicaMapHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/TokenRangeReplicaMapHandler.java index ccf15dda0..269e0f1ad 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/TokenRangeReplicaMapHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/TokenRangeReplicaMapHandler.java @@ -18,6 +18,9 @@ package org.apache.cassandra.sidecar.routes; +import java.util.Set; + +import com.google.common.collect.ImmutableSet; import org.apache.commons.lang3.StringUtils; import com.datastax.driver.core.Metadata; @@ -26,7 +29,10 @@ import io.netty.handler.codec.http.HttpResponseStatus; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.cluster.CassandraAdapterDelegate; import org.apache.cassandra.sidecar.common.server.StorageOperations; import org.apache.cassandra.sidecar.common.server.data.Name; @@ -48,7 +54,7 @@ * {@code org.apache.cassandra.sidecar.adapters.base.TokenRangeReplicaProvider.StateWithReplacement} */ @Singleton -public class TokenRangeReplicaMapHandler extends AbstractHandler +public class TokenRangeReplicaMapHandler extends AbstractHandler implements AccessProtected { @Inject @@ -59,6 +65,13 @@ public TokenRangeReplicaMapHandler(InstanceMetadataFetcher metadataFetcher, super(metadataFetcher, executorPools, validator); } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.DATA_WITH_KEYSPACE.resource(); + return ImmutableSet.of(SidecarActions.VIEW_TOPOLOGY.toAuthorization(resource)); + } + /** * {@inheritDoc} */ diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/cassandra/NodeSettingsHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/cassandra/NodeSettingsHandler.java index 75e7ad5fa..6e622cfb9 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/cassandra/NodeSettingsHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/cassandra/NodeSettingsHandler.java @@ -19,15 +19,23 @@ package org.apache.cassandra.sidecar.routes.cassandra; +import java.util.Set; + +import com.google.common.collect.ImmutableSet; + import com.google.inject.Inject; import com.google.inject.Singleton; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.cluster.CassandraAdapterDelegate; import org.apache.cassandra.sidecar.common.response.NodeSettings; import org.apache.cassandra.sidecar.concurrent.ExecutorPools; import org.apache.cassandra.sidecar.routes.AbstractHandler; +import org.apache.cassandra.sidecar.routes.AccessProtected; import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher; import static org.apache.cassandra.sidecar.utils.HttpExceptions.cassandraServiceUnavailable; @@ -36,7 +44,7 @@ * Provides REST endpoint to get the configured settings of a cassandra node */ @Singleton -public class NodeSettingsHandler extends AbstractHandler +public class NodeSettingsHandler extends AbstractHandler implements AccessProtected { /** * Constructs a handler with the provided {@code metadataFetcher} @@ -49,6 +57,13 @@ public class NodeSettingsHandler extends AbstractHandler super(metadataFetcher, executorPools, null); } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.CLUSTER.resource(); + return ImmutableSet.of(SidecarActions.VIEW_CLUSTER.toAuthorization(resource)); + } + /** * {@inheritDoc} */ diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/cdc/ListCdcDirHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/cdc/ListCdcDirHandler.java index 2f7a25402..7f97712e9 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/cdc/ListCdcDirHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/cdc/ListCdcDirHandler.java @@ -28,6 +28,7 @@ import java.util.List; import java.util.Set; +import com.google.common.collect.ImmutableSet; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,7 +37,10 @@ import io.netty.handler.codec.http.HttpResponseStatus; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.common.response.ListCdcSegmentsResponse; import org.apache.cassandra.sidecar.common.response.data.CdcSegmentInfo; import org.apache.cassandra.sidecar.concurrent.ExecutorPools; @@ -44,6 +48,7 @@ import org.apache.cassandra.sidecar.config.ServiceConfiguration; import org.apache.cassandra.sidecar.config.SidecarConfiguration; import org.apache.cassandra.sidecar.routes.AbstractHandler; +import org.apache.cassandra.sidecar.routes.AccessProtected; import org.apache.cassandra.sidecar.utils.CassandraInputValidator; import org.apache.cassandra.sidecar.utils.CdcUtil; import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher; @@ -57,7 +62,7 @@ * Provides REST endpoint for listing commit logs in CDC directory. */ @Singleton -public class ListCdcDirHandler extends AbstractHandler +public class ListCdcDirHandler extends AbstractHandler implements AccessProtected { private static final Logger LOGGER = LoggerFactory.getLogger(ListCdcDirHandler.class); private final ServiceConfiguration config; @@ -74,6 +79,13 @@ public ListCdcDirHandler(InstanceMetadataFetcher metadataFetcher, this.serviceExecutorPool = executorPools.service(); } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.CLUSTER.resource(); + return ImmutableSet.of(SidecarActions.VIEW_CDC.toAuthorization(resource)); + } + @Override protected void handleInternal(RoutingContext context, HttpServerRequest httpRequest, diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/cdc/StreamCdcSegmentHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/cdc/StreamCdcSegmentHandler.java index 0c184e77f..510614f63 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/cdc/StreamCdcSegmentHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/cdc/StreamCdcSegmentHandler.java @@ -20,7 +20,9 @@ import java.io.File; import java.io.IOException; +import java.util.Set; +import com.google.common.collect.ImmutableSet; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -31,14 +33,18 @@ import io.vertx.core.Future; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.handler.HttpException; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.cdc.CdcLogCache; import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata; import org.apache.cassandra.sidecar.concurrent.ExecutorPools; import org.apache.cassandra.sidecar.concurrent.TaskExecutorPool; import org.apache.cassandra.sidecar.models.HttpResponse; import org.apache.cassandra.sidecar.routes.AbstractHandler; +import org.apache.cassandra.sidecar.routes.AccessProtected; import org.apache.cassandra.sidecar.utils.CassandraInputValidator; import org.apache.cassandra.sidecar.utils.CdcUtil; import org.apache.cassandra.sidecar.utils.FileStreamer; @@ -54,7 +60,7 @@ * Provides REST endpoint for streaming cdc commit logs. */ @Singleton -public class StreamCdcSegmentHandler extends AbstractHandler +public class StreamCdcSegmentHandler extends AbstractHandler implements AccessProtected { private static final Logger LOGGER = LoggerFactory.getLogger(StreamCdcSegmentHandler.class); @@ -75,6 +81,13 @@ public StreamCdcSegmentHandler(InstanceMetadataFetcher metadataFetcher, this.serviceExecutorPool = executorPools.service(); } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.CLUSTER.resource(); + return ImmutableSet.of(SidecarActions.STREAM_CDC.toAuthorization(resource)); + } + @Override protected void handleInternal(RoutingContext context, HttpServerRequest httpRequest, diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/AbortRestoreJobHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/AbortRestoreJobHandler.java index 38453a953..cf1c90643 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/AbortRestoreJobHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/AbortRestoreJobHandler.java @@ -18,19 +18,27 @@ package org.apache.cassandra.sidecar.routes.restore; +import java.util.Set; + +import com.google.common.collect.ImmutableSet; + import com.google.inject.Inject; import com.google.inject.Singleton; import io.netty.handler.codec.http.HttpResponseStatus; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.json.Json; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.common.request.data.AbortRestoreJobRequestPayload; import org.apache.cassandra.sidecar.concurrent.ExecutorPools; import org.apache.cassandra.sidecar.db.RestoreJobDatabaseAccessor; import org.apache.cassandra.sidecar.metrics.RestoreMetrics; import org.apache.cassandra.sidecar.metrics.SidecarMetrics; import org.apache.cassandra.sidecar.routes.AbstractHandler; +import org.apache.cassandra.sidecar.routes.AccessProtected; import org.apache.cassandra.sidecar.routes.RoutingContextUtils; import org.apache.cassandra.sidecar.utils.CassandraInputValidator; import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher; @@ -44,7 +52,7 @@ * {@link org.apache.cassandra.sidecar.db.RestoreJob} */ @Singleton -public class AbortRestoreJobHandler extends AbstractHandler +public class AbortRestoreJobHandler extends AbstractHandler implements AccessProtected { private static final AbortRestoreJobRequestPayload EMPTY_PAYLOAD = new AbortRestoreJobRequestPayload(null); @@ -63,6 +71,13 @@ public AbortRestoreJobHandler(ExecutorPools executorPools, this.metrics = metrics.server().restore(); } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.DATA_WITH_KEYSPACE_TABLE.resource(); + return ImmutableSet.of(SidecarActions.ABORT_RESTORE.toAuthorization(resource)); + } + @Override protected void handleInternal(RoutingContext context, HttpServerRequest httpRequest, diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/CreateRestoreJobHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/CreateRestoreJobHandler.java index 22a9e53ae..e863f1a3b 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/CreateRestoreJobHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/CreateRestoreJobHandler.java @@ -18,23 +18,30 @@ package org.apache.cassandra.sidecar.routes.restore; +import java.util.Set; import java.util.UUID; import javax.inject.Inject; import javax.inject.Singleton; +import com.google.common.collect.ImmutableSet; + import io.netty.handler.codec.http.HttpResponseStatus; import io.vertx.core.Future; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.json.DecodeException; import io.vertx.core.json.Json; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.common.request.data.CreateRestoreJobRequestPayload; import org.apache.cassandra.sidecar.common.response.data.CreateRestoreJobResponsePayload; import org.apache.cassandra.sidecar.concurrent.ExecutorPools; import org.apache.cassandra.sidecar.db.RestoreJob; import org.apache.cassandra.sidecar.db.RestoreJobDatabaseAccessor; import org.apache.cassandra.sidecar.routes.AbstractHandler; +import org.apache.cassandra.sidecar.routes.AccessProtected; import org.apache.cassandra.sidecar.routes.RoutingContextUtils; import org.apache.cassandra.sidecar.utils.CassandraInputValidator; import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher; @@ -46,7 +53,7 @@ * Provides REST API for creating a new restore job for restoring data into Cassandra through Sidecar */ @Singleton -public class CreateRestoreJobHandler extends AbstractHandler +public class CreateRestoreJobHandler extends AbstractHandler implements AccessProtected { private final RestoreJobDatabaseAccessor restoreJobDatabaseAccessor; @@ -60,6 +67,13 @@ public CreateRestoreJobHandler(ExecutorPools executorPools, this.restoreJobDatabaseAccessor = restoreJobDatabaseAccessor; } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.DATA_WITH_KEYSPACE_TABLE.resource(); + return ImmutableSet.of(SidecarActions.CREATE_RESTORE.toAuthorization(resource)); + } + @Override protected void handleInternal(RoutingContext context, HttpServerRequest httpRequest, diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/CreateRestoreSliceHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/CreateRestoreSliceHandler.java index ad7f62979..d216a94d8 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/CreateRestoreSliceHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/CreateRestoreSliceHandler.java @@ -19,15 +19,21 @@ package org.apache.cassandra.sidecar.routes.restore; import java.nio.file.Paths; +import java.util.Set; import javax.inject.Inject; +import com.google.common.collect.ImmutableSet; + import io.netty.handler.codec.http.HttpResponseStatus; import io.vertx.core.Future; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.json.DecodeException; import io.vertx.core.json.Json; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata; import org.apache.cassandra.sidecar.common.request.data.CreateSliceRequestPayload; import org.apache.cassandra.sidecar.concurrent.ExecutorPools; @@ -40,6 +46,7 @@ import org.apache.cassandra.sidecar.restore.RestoreJobProgressTracker; import org.apache.cassandra.sidecar.restore.RestoreJobUtil; import org.apache.cassandra.sidecar.routes.AbstractHandler; +import org.apache.cassandra.sidecar.routes.AccessProtected; import org.apache.cassandra.sidecar.routes.RoutingContextUtils; import org.apache.cassandra.sidecar.utils.CassandraInputValidator; import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher; @@ -51,7 +58,7 @@ /** * Provides a REST API for creating new {@link RestoreSlice} under a {@link RestoreJob} */ -public class CreateRestoreSliceHandler extends AbstractHandler +public class CreateRestoreSliceHandler extends AbstractHandler implements AccessProtected { private static final int SERVER_ERROR_RESTORE_JOB_FAILED = 550; private final RestoreJobManagerGroup restoreJobManagerGroup; @@ -69,6 +76,13 @@ public CreateRestoreSliceHandler(ExecutorPools executorPools, this.restoreSliceDatabaseAccessor = restoreSliceDatabaseAccessor; } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.DATA_WITH_KEYSPACE_TABLE.resource(); + return ImmutableSet.of(SidecarActions.CREATE_RESTORE.toAuthorization(resource)); + } + @Override protected void handleInternal(RoutingContext context, HttpServerRequest httpRequest, diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/RestoreJobProgressHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/RestoreJobProgressHandler.java index 84bb5165b..1e32af570 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/RestoreJobProgressHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/RestoreJobProgressHandler.java @@ -17,13 +17,21 @@ package org.apache.cassandra.sidecar.routes.restore; import java.util.List; +import java.util.Set; + +import com.google.common.collect.ImmutableSet; import com.google.inject.Inject; import com.google.inject.Singleton; import io.netty.handler.codec.http.HttpResponseStatus; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; +import io.vertx.ext.auth.authorization.OrAuthorization; +import io.vertx.ext.auth.authorization.impl.OrAuthorizationImpl; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.common.ApiEndpointsV1; import org.apache.cassandra.sidecar.common.data.RestoreJobProgressFetchPolicy; import org.apache.cassandra.sidecar.concurrent.ExecutorPools; @@ -31,6 +39,7 @@ import org.apache.cassandra.sidecar.restore.RestoreJobConsistencyLevelChecker; import org.apache.cassandra.sidecar.restore.RestoreJobProgress; import org.apache.cassandra.sidecar.routes.AbstractHandler; +import org.apache.cassandra.sidecar.routes.AccessProtected; import org.apache.cassandra.sidecar.routes.RoutingContextUtils; import org.apache.cassandra.sidecar.utils.CassandraInputValidator; import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher; @@ -43,7 +52,7 @@ * The response content can vary based on the {@link RestoreJobProgressFetchPolicy} */ @Singleton -public class RestoreJobProgressHandler extends AbstractHandler +public class RestoreJobProgressHandler extends AbstractHandler implements AccessProtected { private final RestoreJobConsistencyLevelChecker consistencyLevelChecker; @@ -64,6 +73,17 @@ public RestoreJobProgressHandler(InstanceMetadataFetcher metadataFetcher, this.consistencyLevelChecker = consistencyLevelChecker; } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.DATA_WITH_KEYSPACE_TABLE.resource(); + Authorization createRestore = SidecarActions.CREATE_RESTORE.toAuthorization(resource); + Authorization viewRestore = SidecarActions.VIEW_RESTORE.toAuthorization(resource); + OrAuthorization createOrView + = new OrAuthorizationImpl().addAuthorization(createRestore).addAuthorization(viewRestore); + return ImmutableSet.of(createOrView); + } + @Override protected RestoreJobProgressFetchPolicy extractParamsOrThrow(RoutingContext context) { diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/RestoreJobSummaryHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/RestoreJobSummaryHandler.java index 4831b3449..d06213d4b 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/RestoreJobSummaryHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/RestoreJobSummaryHandler.java @@ -18,17 +18,26 @@ package org.apache.cassandra.sidecar.routes.restore; +import java.util.Set; import javax.inject.Inject; import javax.inject.Singleton; +import com.google.common.collect.ImmutableSet; + import io.netty.handler.codec.http.HttpResponseStatus; import io.vertx.core.Future; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; +import io.vertx.ext.auth.authorization.OrAuthorization; +import io.vertx.ext.auth.authorization.impl.OrAuthorizationImpl; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.common.response.data.RestoreJobSummaryResponsePayload; import org.apache.cassandra.sidecar.concurrent.ExecutorPools; import org.apache.cassandra.sidecar.routes.AbstractHandler; +import org.apache.cassandra.sidecar.routes.AccessProtected; import org.apache.cassandra.sidecar.routes.RoutingContextUtils; import org.apache.cassandra.sidecar.utils.CassandraInputValidator; import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher; @@ -40,7 +49,7 @@ * Provides a REST API for providing summary of restore job maintained by Sidecar */ @Singleton -public class RestoreJobSummaryHandler extends AbstractHandler +public class RestoreJobSummaryHandler extends AbstractHandler implements AccessProtected { @Inject public RestoreJobSummaryHandler(ExecutorPools executorPools, @@ -50,6 +59,17 @@ public RestoreJobSummaryHandler(ExecutorPools executorPools, super(instanceMetadataFetcher, executorPools, validator); } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.DATA_WITH_KEYSPACE_TABLE.resource(); + Authorization createRestore = SidecarActions.CREATE_RESTORE.toAuthorization(resource); + Authorization viewRestore = SidecarActions.VIEW_RESTORE.toAuthorization(resource); + OrAuthorization createOrView + = new OrAuthorizationImpl().addAuthorization(createRestore).addAuthorization(viewRestore); + return ImmutableSet.of(createOrView); + } + @Override protected void handleInternal(RoutingContext context, HttpServerRequest httpRequest, diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/UpdateRestoreJobHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/UpdateRestoreJobHandler.java index 60b7687be..54bdd5912 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/UpdateRestoreJobHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/restore/UpdateRestoreJobHandler.java @@ -18,8 +18,11 @@ package org.apache.cassandra.sidecar.routes.restore; +import java.util.Set; import java.util.concurrent.TimeUnit; +import com.google.common.collect.ImmutableSet; + import com.datastax.driver.core.utils.UUIDs; import com.google.inject.Inject; import com.google.inject.Singleton; @@ -29,7 +32,12 @@ import io.vertx.core.json.DecodeException; import io.vertx.core.json.Json; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; +import io.vertx.ext.auth.authorization.OrAuthorization; +import io.vertx.ext.auth.authorization.impl.OrAuthorizationImpl; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.common.data.RestoreJobStatus; import org.apache.cassandra.sidecar.common.request.data.UpdateRestoreJobRequestPayload; import org.apache.cassandra.sidecar.concurrent.ExecutorPools; @@ -38,6 +46,7 @@ import org.apache.cassandra.sidecar.metrics.RestoreMetrics; import org.apache.cassandra.sidecar.metrics.SidecarMetrics; import org.apache.cassandra.sidecar.routes.AbstractHandler; +import org.apache.cassandra.sidecar.routes.AccessProtected; import org.apache.cassandra.sidecar.routes.RoutingContextUtils; import org.apache.cassandra.sidecar.utils.CassandraInputValidator; import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher; @@ -49,7 +58,7 @@ * Provides a REST API to update {@link RestoreJob} */ @Singleton -public class UpdateRestoreJobHandler extends AbstractHandler +public class UpdateRestoreJobHandler extends AbstractHandler implements AccessProtected { private final RestoreJobDatabaseAccessor restoreJobDatabaseAccessor; private final RestoreMetrics metrics; @@ -66,6 +75,17 @@ public UpdateRestoreJobHandler(ExecutorPools executorPools, this.metrics = metrics.server().restore(); } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.DATA_WITH_KEYSPACE_TABLE.resource(); + Authorization createRestore = SidecarActions.CREATE_RESTORE.toAuthorization(resource); + Authorization updateRestore = SidecarActions.UPDATE_RESTORE.toAuthorization(resource); + OrAuthorization createOrUpdate + = new OrAuthorizationImpl().addAuthorization(createRestore).addAuthorization(updateRestore); + return ImmutableSet.of(createOrUpdate); + } + @Override protected void handleInternal(RoutingContext context, HttpServerRequest httpRequest, diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/snapshots/ClearSnapshotHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/snapshots/ClearSnapshotHandler.java index 43849bcf1..92f2644bd 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/snapshots/ClearSnapshotHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/snapshots/ClearSnapshotHandler.java @@ -20,17 +20,24 @@ import java.io.FileNotFoundException; import java.nio.file.NoSuchFileException; +import java.util.Set; + +import com.google.common.collect.ImmutableSet; import com.google.inject.Inject; import com.google.inject.Singleton; import io.netty.handler.codec.http.HttpResponseStatus; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.cluster.CassandraAdapterDelegate; import org.apache.cassandra.sidecar.common.server.StorageOperations; import org.apache.cassandra.sidecar.concurrent.ExecutorPools; import org.apache.cassandra.sidecar.routes.AbstractHandler; +import org.apache.cassandra.sidecar.routes.AccessProtected; import org.apache.cassandra.sidecar.routes.data.SnapshotRequestParam; import org.apache.cassandra.sidecar.utils.CassandraInputValidator; import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher; @@ -42,7 +49,7 @@ * The DELETE verb deletes an existing snapshot for the given keyspace and table. */ @Singleton -public class ClearSnapshotHandler extends AbstractHandler +public class ClearSnapshotHandler extends AbstractHandler implements AccessProtected { @Inject public ClearSnapshotHandler(InstanceMetadataFetcher metadataFetcher, @@ -52,6 +59,13 @@ public ClearSnapshotHandler(InstanceMetadataFetcher metadataFetcher, super(metadataFetcher, executorPools, validator); } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.DATA_WITH_KEYSPACE_TABLE.resource(); + return ImmutableSet.of(SidecarActions.DELETE_SNAPSHOT.toAuthorization(resource)); + } + /** * Clears a snapshot for the given keyspace and table. **Note**: Currently, Cassandra does not support * the table parameter. We can add support in Cassandra for the additional parameter. diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/snapshots/CreateSnapshotHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/snapshots/CreateSnapshotHandler.java index 4c58a7b3f..83daa9dec 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/snapshots/CreateSnapshotHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/snapshots/CreateSnapshotHandler.java @@ -19,8 +19,10 @@ package org.apache.cassandra.sidecar.routes.snapshots; import java.util.Map; +import java.util.Set; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; import org.apache.commons.lang3.StringUtils; import com.google.inject.Inject; @@ -29,7 +31,10 @@ import io.vertx.core.http.HttpServerRequest; import io.vertx.core.json.JsonObject; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.cluster.CassandraAdapterDelegate; import org.apache.cassandra.sidecar.common.server.StorageOperations; import org.apache.cassandra.sidecar.common.server.exceptions.NodeBootstrappingException; @@ -37,6 +42,7 @@ import org.apache.cassandra.sidecar.concurrent.ExecutorPools; import org.apache.cassandra.sidecar.concurrent.TaskExecutorPool; import org.apache.cassandra.sidecar.routes.AbstractHandler; +import org.apache.cassandra.sidecar.routes.AccessProtected; import org.apache.cassandra.sidecar.routes.data.SnapshotRequestParam; import org.apache.cassandra.sidecar.utils.CassandraInputValidator; import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher; @@ -48,7 +54,7 @@ * The PUT verb creates a new snapshot for the given keyspace and table */ @Singleton -public class CreateSnapshotHandler extends AbstractHandler +public class CreateSnapshotHandler extends AbstractHandler implements AccessProtected { private static final String TTL_QUERY_PARAM = "ttl"; @@ -60,6 +66,13 @@ public CreateSnapshotHandler(InstanceMetadataFetcher metadataFetcher, super(metadataFetcher, executorPools, validator); } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.DATA_WITH_KEYSPACE_TABLE.resource(); + return ImmutableSet.of(SidecarActions.CREATE_SNAPSHOT.toAuthorization(resource)); + } + /** * Creates a new snapshot for the given keyspace and table. * diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/snapshots/ListSnapshotHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/snapshots/ListSnapshotHandler.java index 8fa8d33d0..446093454 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/snapshots/ListSnapshotHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/snapshots/ListSnapshotHandler.java @@ -21,9 +21,12 @@ import java.io.FileNotFoundException; import java.nio.file.NoSuchFileException; import java.util.List; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; +import com.google.common.collect.ImmutableSet; + import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.google.inject.Inject; @@ -32,7 +35,10 @@ import io.vertx.core.Future; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.cluster.CassandraAdapterDelegate; import org.apache.cassandra.sidecar.common.response.ListSnapshotFilesResponse; import org.apache.cassandra.sidecar.common.server.TableOperations; @@ -42,6 +48,7 @@ import org.apache.cassandra.sidecar.metrics.CacheStatsCounter; import org.apache.cassandra.sidecar.metrics.SidecarMetrics; import org.apache.cassandra.sidecar.routes.AbstractHandler; +import org.apache.cassandra.sidecar.routes.AccessProtected; import org.apache.cassandra.sidecar.routes.data.SnapshotRequestParam; import org.apache.cassandra.sidecar.snapshots.SnapshotPathBuilder; import org.apache.cassandra.sidecar.utils.CassandraInputValidator; @@ -67,7 +74,7 @@ * "testSnapshot" snapshot for the "ks" keyspace and the "tbl" table */ @Singleton -public class ListSnapshotHandler extends AbstractHandler +public class ListSnapshotHandler extends AbstractHandler implements AccessProtected { private static final String INCLUDE_SECONDARY_INDEX_FILES_QUERY_PARAM = "includeSecondaryIndexFiles"; public static final String SNAPSHOT_CACHE_NAME = "snapshot_cache"; @@ -91,6 +98,13 @@ public ListSnapshotHandler(SnapshotPathBuilder builder, this.cache = initializeCache(cacheConfiguration, sidecarMetrics.server().cache().snapshotCacheMetrics); } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.DATA_WITH_KEYSPACE_TABLE.resource(); + return ImmutableSet.of(SidecarActions.VIEW_SNAPSHOT.toAuthorization(resource)); + } + /** * Lists paths of all the snapshot files of a given snapshot name. *

diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/sstableuploads/SSTableCleanupHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/sstableuploads/SSTableCleanupHandler.java index bffb9a54b..16aad2b70 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/sstableuploads/SSTableCleanupHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/sstableuploads/SSTableCleanupHandler.java @@ -19,21 +19,28 @@ package org.apache.cassandra.sidecar.routes.sstableuploads; import java.nio.file.NoSuchFileException; +import java.util.Set; + +import com.google.common.collect.ImmutableSet; import com.google.inject.Inject; import io.netty.handler.codec.http.HttpResponseStatus; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.concurrent.ExecutorPools; import org.apache.cassandra.sidecar.routes.AbstractHandler; +import org.apache.cassandra.sidecar.routes.AccessProtected; import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher; import org.apache.cassandra.sidecar.utils.SSTableUploadsPathBuilder; /** * Manages cleaning up uploaded SSTables */ -public class SSTableCleanupHandler extends AbstractHandler +public class SSTableCleanupHandler extends AbstractHandler implements AccessProtected { private static final String UPLOAD_ID_PARAM = "uploadId"; private final SSTableUploadsPathBuilder uploadPathBuilder; @@ -54,6 +61,13 @@ protected SSTableCleanupHandler(InstanceMetadataFetcher metadataFetcher, this.uploadPathBuilder = uploadPathBuilder; } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.DATA_WITH_KEYSPACE_TABLE.resource(); + return ImmutableSet.of(SidecarActions.DELETE_UPLOAD.toAuthorization(resource)); + } + /** * Handles cleaning up the SSTable upload staging directory * diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/sstableuploads/SSTableImportHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/sstableuploads/SSTableImportHandler.java index 93684c1f9..dc3d9735d 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/sstableuploads/SSTableImportHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/sstableuploads/SSTableImportHandler.java @@ -19,6 +19,9 @@ package org.apache.cassandra.sidecar.routes.sstableuploads; import java.nio.file.NoSuchFileException; +import java.util.Set; + +import com.google.common.collect.ImmutableSet; import com.github.benmanes.caffeine.cache.Cache; import com.google.inject.Inject; @@ -26,13 +29,17 @@ import io.vertx.core.Future; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.handler.HttpException; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.cluster.CassandraAdapterDelegate; import org.apache.cassandra.sidecar.common.response.SSTableImportResponse; import org.apache.cassandra.sidecar.common.server.TableOperations; import org.apache.cassandra.sidecar.concurrent.ExecutorPools; import org.apache.cassandra.sidecar.routes.AbstractHandler; +import org.apache.cassandra.sidecar.routes.AccessProtected; import org.apache.cassandra.sidecar.routes.data.SSTableImportRequestParam; import org.apache.cassandra.sidecar.utils.CacheFactory; import org.apache.cassandra.sidecar.utils.CassandraInputValidator; @@ -46,7 +53,7 @@ /** * Imports SSTables, that have been previously uploaded, into Cassandra */ -public class SSTableImportHandler extends AbstractHandler +public class SSTableImportHandler extends AbstractHandler implements AccessProtected { private final SSTableImporter importer; private final SSTableUploadsPathBuilder uploadPathBuilder; @@ -77,6 +84,13 @@ protected SSTableImportHandler(InstanceMetadataFetcher metadataFetcher, this.cache = cacheFactory.ssTableImportCache(); } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.DATA_WITH_KEYSPACE_TABLE.resource(); + return ImmutableSet.of(SidecarActions.IMPORT_SSTABLE.toAuthorization(resource)); + } + /** * Import SSTables, that have been previously uploaded, into the Cassandra service * diff --git a/server/src/main/java/org/apache/cassandra/sidecar/routes/sstableuploads/SSTableUploadHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/routes/sstableuploads/SSTableUploadHandler.java index 2625abe4e..557812231 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/routes/sstableuploads/SSTableUploadHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/routes/sstableuploads/SSTableUploadHandler.java @@ -18,8 +18,11 @@ package org.apache.cassandra.sidecar.routes.sstableuploads; +import java.util.Set; import java.util.concurrent.TimeUnit; +import com.google.common.collect.ImmutableSet; + import com.datastax.driver.core.KeyspaceMetadata; import com.datastax.driver.core.Metadata; import com.google.inject.Inject; @@ -30,7 +33,10 @@ import io.vertx.core.file.FileSystem; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.SidecarActions; +import org.apache.cassandra.sidecar.acl.authorization.VariableAwareResource; import org.apache.cassandra.sidecar.cluster.CassandraAdapterDelegate; import org.apache.cassandra.sidecar.common.response.SSTableUploadResponse; import org.apache.cassandra.sidecar.concurrent.ConcurrencyLimiter; @@ -42,6 +48,7 @@ import org.apache.cassandra.sidecar.metrics.instance.InstanceResourceMetrics; import org.apache.cassandra.sidecar.metrics.instance.UploadSSTableMetrics; import org.apache.cassandra.sidecar.routes.AbstractHandler; +import org.apache.cassandra.sidecar.routes.AccessProtected; import org.apache.cassandra.sidecar.routes.data.SSTableUploadRequestParam; import org.apache.cassandra.sidecar.utils.CassandraInputValidator; import org.apache.cassandra.sidecar.utils.DigestVerifier; @@ -59,7 +66,7 @@ * Handler for managing uploaded SSTable components */ @Singleton -public class SSTableUploadHandler extends AbstractHandler +public class SSTableUploadHandler extends AbstractHandler implements AccessProtected { private final FileSystem fs; private final SSTableUploadConfiguration configuration; @@ -99,6 +106,13 @@ protected SSTableUploadHandler(Vertx vertx, this.digestVerifierFactory = digestVerifierFactory; } + @Override + public Set requiredAuthorizations() + { + String resource = VariableAwareResource.DATA_WITH_KEYSPACE_TABLE.resource(); + return ImmutableSet.of(SidecarActions.UPLOAD_SSTABLE.toAuthorization(resource)); + } + /** * {@inheritDoc} */ diff --git a/server/src/main/java/org/apache/cassandra/sidecar/server/MainModule.java b/server/src/main/java/org/apache/cassandra/sidecar/server/MainModule.java index d948d0fd0..1ea8ee2b3 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/server/MainModule.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/server/MainModule.java @@ -23,6 +23,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.function.Supplier; import java.util.stream.Collectors; import com.google.common.util.concurrent.SidecarRateLimiter; @@ -39,19 +40,25 @@ import io.vertx.core.Vertx; import io.vertx.core.VertxOptions; import io.vertx.core.file.FileSystemOptions; +import io.vertx.core.http.HttpMethod; +import io.vertx.ext.auth.authorization.AuthorizationProvider; 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.BodyHandler; import io.vertx.ext.web.handler.ChainAuthHandler; import io.vertx.ext.web.handler.ErrorHandler; import io.vertx.ext.web.handler.LoggerHandler; import io.vertx.ext.web.handler.StaticHandler; import io.vertx.ext.web.handler.TimeoutHandler; +import org.apache.cassandra.sidecar.acl.IdentityToRoleCache; import org.apache.cassandra.sidecar.acl.authentication.AuthenticationHandlerFactory; import org.apache.cassandra.sidecar.acl.authentication.AuthenticationHandlerFactoryRegistry; import org.apache.cassandra.sidecar.acl.authentication.MutualTlsAuthenticationHandlerFactory; +import org.apache.cassandra.sidecar.acl.authorization.AdminIdentityResolver; +import org.apache.cassandra.sidecar.acl.authorization.AllowAllAuthorizationProvider; +import org.apache.cassandra.sidecar.acl.authorization.RoleAuthorizationsCache; +import org.apache.cassandra.sidecar.acl.authorization.RoleBasedAuthorizationProvider; import org.apache.cassandra.sidecar.adapters.base.CassandraFactory; import org.apache.cassandra.sidecar.adapters.cassandra41.Cassandra41Factory; import org.apache.cassandra.sidecar.cluster.CQLSessionProviderImpl; @@ -90,6 +97,7 @@ import org.apache.cassandra.sidecar.db.schema.RestoreSlicesSchema; import org.apache.cassandra.sidecar.db.schema.SidecarInternalKeyspace; import org.apache.cassandra.sidecar.db.schema.SidecarLeaseSchema; +import org.apache.cassandra.sidecar.db.schema.SidecarRolePermissionsSchema; import org.apache.cassandra.sidecar.db.schema.SidecarSchema; import org.apache.cassandra.sidecar.db.schema.SystemAuthSchema; import org.apache.cassandra.sidecar.exceptions.ConfigurationException; @@ -99,12 +107,15 @@ import org.apache.cassandra.sidecar.metrics.SidecarMetrics; import org.apache.cassandra.sidecar.metrics.SidecarMetricsImpl; import org.apache.cassandra.sidecar.metrics.instance.InstanceHealthMetrics; +import org.apache.cassandra.sidecar.routes.AccessProtectedRouteBuilder; import org.apache.cassandra.sidecar.routes.CassandraHealthHandler; import org.apache.cassandra.sidecar.routes.ConnectedClientStatsHandler; import org.apache.cassandra.sidecar.routes.DiskSpaceProtectionHandler; 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.KeyspaceRingHandler; +import org.apache.cassandra.sidecar.routes.KeyspaceSchemaHandler; import org.apache.cassandra.sidecar.routes.ListOperationalJobsHandler; import org.apache.cassandra.sidecar.routes.OperationalJobHandler; import org.apache.cassandra.sidecar.routes.RingHandler; @@ -245,11 +256,52 @@ public ChainAuthHandler chainAuthHandler(Vertx vertx, return chainAuthHandler; } + @Provides + @Singleton + public AuthorizationProvider authorizationProvider(SidecarConfiguration sidecarConfiguration, + IdentityToRoleCache identityToRoleCache, + RoleAuthorizationsCache roleAuthorizationsCache) + { + AccessControlConfiguration accessControlConfiguration = sidecarConfiguration.accessControlConfiguration(); + if (!accessControlConfiguration.enabled()) + { + return AllowAllAuthorizationProvider.INSTANCE; + } + + ParameterizedClassConfiguration config = accessControlConfiguration.authorizerConfiguration(); + if (config == null) + { + throw new ConfigurationException("Access control is enabled, but authorizer not set"); + } + + if (config.className().equalsIgnoreCase(AllowAllAuthorizationProvider.class.getName())) + { + return AllowAllAuthorizationProvider.INSTANCE; + } + if (config.className().equalsIgnoreCase(RoleBasedAuthorizationProvider.class.getName())) + { + return new RoleBasedAuthorizationProvider(identityToRoleCache, roleAuthorizationsCache); + } + throw new ConfigurationException("Unrecognized authorization provider " + config.className() + " set"); + } + + @Provides + @Singleton + public Supplier accessProtectedRouteBuilderFactory(SidecarConfiguration sidecarConfiguration, + AuthorizationProvider authorizationProvider, + AdminIdentityResolver adminIdentityResolver) + { + return () -> new AccessProtectedRouteBuilder(sidecarConfiguration.accessControlConfiguration(), + authorizationProvider, + adminIdentityResolver); + } + @Provides @Singleton public Router vertxRouter(Vertx vertx, SidecarConfiguration sidecarConfiguration, ChainAuthHandler chainAuthHandler, + Supplier protectedRouteBuilderFactory, ServiceConfiguration conf, CassandraHealthHandler cassandraHealthHandler, StreamSSTableComponentHandler streamSSTableComponentHandler, @@ -258,7 +310,9 @@ public Router vertxRouter(Vertx vertx, CreateSnapshotHandler createSnapshotHandler, ListSnapshotHandler listSnapshotHandler, SchemaHandler schemaHandler, + KeyspaceSchemaHandler keyspaceSchemaHandler, RingHandler ringHandler, + KeyspaceRingHandler keyspaceRingHandler, TokenRangeReplicaMapHandler tokenRangeHandler, LoggerHandler loggerHandler, GossipInfoHandler gossipInfoHandler, @@ -308,7 +362,8 @@ public Router vertxRouter(Vertx vertx, .handler(docs); // Add custom routers - // Provides a simple REST endpoint to determine if Sidecar is available + // Provides a simple REST endpoint to determine if Sidecar is available. Health endpoints in Sidecar are + // authenticated and exempted from authorization. router.get(ApiEndpointsV1.HEALTH_ROUTE) .handler(context -> context.json(OK_STATUS)); @@ -323,127 +378,193 @@ public Router vertxRouter(Vertx vertx, router.get(ApiEndpointsV1.CASSANDRA_JMX_HEALTH_ROUTE) .handler(cassandraHealthHandler); - //noinspection deprecation - router.get(ApiEndpointsV1.DEPRECATED_COMPONENTS_ROUTE) - .handler(streamSSTableComponentHandler) - .handler(fileStreamHandler); + // NOTE: All routes in Sidecar must be built with AccessProtectedRouteBuilder. AccessProtectedRouteBuilder, + // skips adding AuthorizationHandler in handler chain if access control is disabled. - router.get(ApiEndpointsV1.COMPONENTS_ROUTE) - .handler(streamSSTableComponentHandler) - .handler(fileStreamHandler); + //noinspection deprecation + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.GET) + .endpoint(ApiEndpointsV1.DEPRECATED_COMPONENTS_ROUTE) + .handler(streamSSTableComponentHandler) + .handler(fileStreamHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.GET) + .endpoint(ApiEndpointsV1.COMPONENTS_ROUTE) + .handler(streamSSTableComponentHandler) + .handler(fileStreamHandler) + .build(); // Support for routes that want to stream SStable index components - router.get(ApiEndpointsV1.COMPONENTS_WITH_SECONDARY_INDEX_ROUTE_SUPPORT) - .handler(streamSSTableComponentHandler) - .handler(fileStreamHandler); + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.GET) + .endpoint(ApiEndpointsV1.COMPONENTS_WITH_SECONDARY_INDEX_ROUTE_SUPPORT) + .handler(streamSSTableComponentHandler) + .handler(fileStreamHandler) + .build(); //noinspection deprecation - router.get(ApiEndpointsV1.DEPRECATED_SNAPSHOTS_ROUTE) - .handler(listSnapshotHandler); - - router.get(ApiEndpointsV1.SNAPSHOTS_ROUTE) - .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(validateTableExistence) - .handler(clearSnapshotHandler); - - router.put(ApiEndpointsV1.SNAPSHOTS_ROUTE) - .handler(createSnapshotHandler); + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.GET) + .endpoint(ApiEndpointsV1.DEPRECATED_SNAPSHOTS_ROUTE) + .handler(listSnapshotHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.GET) + .endpoint(ApiEndpointsV1.SNAPSHOTS_ROUTE) + .handler(listSnapshotHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.DELETE) + .endpoint(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(validateTableExistence) + .handler(clearSnapshotHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.PUT) + .endpoint(ApiEndpointsV1.SNAPSHOTS_ROUTE) + .handler(createSnapshotHandler) + .build(); //noinspection deprecation - router.get(ApiEndpointsV1.DEPRECATED_ALL_KEYSPACES_SCHEMA_ROUTE) - .handler(schemaHandler); + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.GET) + .endpoint(ApiEndpointsV1.DEPRECATED_ALL_KEYSPACES_SCHEMA_ROUTE) + .handler(schemaHandler) + .build(); - router.get(ApiEndpointsV1.ALL_KEYSPACES_SCHEMA_ROUTE) - .handler(schemaHandler); + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.GET) + .endpoint(ApiEndpointsV1.ALL_KEYSPACES_SCHEMA_ROUTE) + .handler(schemaHandler) + .build(); //noinspection deprecation - router.get(ApiEndpointsV1.DEPRECATED_KEYSPACE_SCHEMA_ROUTE) - .handler(schemaHandler); - - router.get(ApiEndpointsV1.KEYSPACE_SCHEMA_ROUTE) - .handler(schemaHandler); - - router.get(ApiEndpointsV1.RING_ROUTE) - .handler(ringHandler); - - router.get(ApiEndpointsV1.CONNECTED_CLIENT_STATS_ROUTE) - .handler(connectedClientStatsHandler); - - router.get(ApiEndpointsV1.OPERATIONAL_JOB_ROUTE) - .handler(operationalJobHandler); - - router.get(ApiEndpointsV1.LIST_OPERATIONAL_JOBS_ROUTE) - .handler(listOperationalJobsHandler); - - router.get(ApiEndpointsV1.RING_ROUTE_PER_KEYSPACE) - .handler(ringHandler); - - router.put(ApiEndpointsV1.SSTABLE_UPLOAD_ROUTE) - .handler(ssTableUploadHandler); - - router.get(ApiEndpointsV1.KEYSPACE_TOKEN_MAPPING_ROUTE) - .handler(tokenRangeHandler); - - router.put(ApiEndpointsV1.SSTABLE_IMPORT_ROUTE) - .handler(ssTableImportHandler); - - router.delete(ApiEndpointsV1.SSTABLE_CLEANUP_ROUTE) - .handler(ssTableCleanupHandler); - - router.get(ApiEndpointsV1.GOSSIP_INFO_ROUTE) - .handler(gossipInfoHandler); - - router.get(ApiEndpointsV1.TIME_SKEW_ROUTE) - .handler(timeSkewHandler); - - router.get(ApiEndpointsV1.NODE_SETTINGS_ROUTE) - .handler(nodeSettingsHandler); - - router.post(ApiEndpointsV1.CREATE_RESTORE_JOB_ROUTE) - .handler(BodyHandler.create()) - .handler(validateTableExistence) - .handler(validateRestoreJobRequest) - .handler(createRestoreJobHandler); - - router.post(ApiEndpointsV1.RESTORE_JOB_SLICES_ROUTE) - .handler(BodyHandler.create()) - .handler(diskSpaceProtection) // reject creating slice if short of disk space - .handler(validateTableExistence) - .handler(validateRestoreJobRequest) - .handler(createRestoreSliceHandler); - - router.get(ApiEndpointsV1.RESTORE_JOB_ROUTE) - .handler(validateTableExistence) - .handler(validateRestoreJobRequest) - .handler(restoreJobSummaryHandler); - - router.patch(ApiEndpointsV1.RESTORE_JOB_ROUTE) - .handler(BodyHandler.create()) - .handler(validateTableExistence) - .handler(validateRestoreJobRequest) - .handler(updateRestoreJobHandler); - - router.post(ApiEndpointsV1.ABORT_RESTORE_JOB_ROUTE) - .handler(BodyHandler.create()) - .handler(validateTableExistence) - .handler(validateRestoreJobRequest) - .handler(abortRestoreJobHandler); - - router.get(ApiEndpointsV1.RESTORE_JOB_PROGRESS_ROUTE) - .handler(validateTableExistence) - .handler(validateRestoreJobRequest) - .handler(restoreJobProgressHandler); + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.GET) + .endpoint(ApiEndpointsV1.DEPRECATED_KEYSPACE_SCHEMA_ROUTE) + .handler(keyspaceSchemaHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.GET) + .endpoint(ApiEndpointsV1.KEYSPACE_SCHEMA_ROUTE) + .handler(keyspaceSchemaHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.GET) + .endpoint(ApiEndpointsV1.RING_ROUTE) + .handler(ringHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.GET) + .endpoint(ApiEndpointsV1.RING_ROUTE_PER_KEYSPACE) + .handler(keyspaceRingHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.GET) + .endpoint(ApiEndpointsV1.CONNECTED_CLIENT_STATS_ROUTE) + .handler(connectedClientStatsHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.GET) + .endpoint(ApiEndpointsV1.OPERATIONAL_JOB_ROUTE) + .handler(operationalJobHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.GET) + .endpoint(ApiEndpointsV1.LIST_OPERATIONAL_JOBS_ROUTE) + .handler(listOperationalJobsHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.PUT) + .endpoint(ApiEndpointsV1.SSTABLE_UPLOAD_ROUTE) + .handler(ssTableUploadHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.GET) + .endpoint(ApiEndpointsV1.KEYSPACE_TOKEN_MAPPING_ROUTE) + .handler(tokenRangeHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.PUT) + .endpoint(ApiEndpointsV1.SSTABLE_IMPORT_ROUTE) + .handler(ssTableImportHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.DELETE) + .endpoint(ApiEndpointsV1.SSTABLE_CLEANUP_ROUTE) + .handler(ssTableCleanupHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.GET) + .endpoint(ApiEndpointsV1.GOSSIP_INFO_ROUTE) + .handler(gossipInfoHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.GET) + .endpoint(ApiEndpointsV1.TIME_SKEW_ROUTE) + .handler(timeSkewHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.GET) + .endpoint(ApiEndpointsV1.NODE_SETTINGS_ROUTE) + .handler(nodeSettingsHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.POST) + .endpoint(ApiEndpointsV1.CREATE_RESTORE_JOB_ROUTE) + .setBodyHandler(true) + .handler(validateTableExistence) + .handler(validateRestoreJobRequest) + .handler(createRestoreJobHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.POST) + .endpoint(ApiEndpointsV1.RESTORE_JOB_SLICES_ROUTE) + .setBodyHandler(true) + .handler(diskSpaceProtection) // reject creating slice if short of disk space + .handler(validateTableExistence) + .handler(validateRestoreJobRequest) + .handler(createRestoreSliceHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.GET) + .endpoint(ApiEndpointsV1.RESTORE_JOB_ROUTE) + .handler(validateTableExistence) + .handler(validateRestoreJobRequest) + .handler(restoreJobSummaryHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.PATCH) + .endpoint(ApiEndpointsV1.RESTORE_JOB_ROUTE) + .setBodyHandler(true) + .handler(validateTableExistence) + .handler(validateRestoreJobRequest) + .handler(updateRestoreJobHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.POST) + .endpoint(ApiEndpointsV1.ABORT_RESTORE_JOB_ROUTE) + .setBodyHandler(true) + .handler(validateTableExistence) + .handler(validateRestoreJobRequest) + .handler(abortRestoreJobHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.GET) + .endpoint(ApiEndpointsV1.RESTORE_JOB_PROGRESS_ROUTE) + .handler(validateTableExistence) + .handler(validateRestoreJobRequest) + .handler(restoreJobProgressHandler) + .build(); // CDC APIs - router.get(ApiEndpointsV1.LIST_CDC_SEGMENTS_ROUTE) - .handler(listCdcDirHandler); - router.get(ApiEndpointsV1.STREAM_CDC_SEGMENTS_ROUTE) - .handler(streamCdcSegmentHandler); + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.GET) + .endpoint(ApiEndpointsV1.LIST_CDC_SEGMENTS_ROUTE) + .handler(listCdcDirHandler) + .build(); + + protectedRouteBuilderFactory.get().router(router).method(HttpMethod.GET) + .endpoint(ApiEndpointsV1.STREAM_CDC_SEGMENTS_ROUTE) + .handler(streamCdcSegmentHandler) + .build(); return router; } @@ -475,10 +596,12 @@ public CassandraInputValidationConfiguration validationConfiguration(SidecarConf @Provides @Singleton - public CQLSessionProvider cqlSessionProvider(Vertx vertx, SidecarConfiguration sidecarConfiguration, + public CQLSessionProvider cqlSessionProvider(Vertx vertx, + SidecarConfiguration sidecarConfiguration, DriverUtils driverUtils) { - CQLSessionProviderImpl cqlSessionProvider = new CQLSessionProviderImpl(sidecarConfiguration, + CQLSessionProviderImpl cqlSessionProvider = new CQLSessionProviderImpl(vertx, + sidecarConfiguration, NettyOptions.DEFAULT_INSTANCE, driverUtils); vertx.eventBus().localConsumer(ON_SERVER_STOP.address(), message -> cqlSessionProvider.close()); @@ -630,6 +753,7 @@ public SidecarSchema sidecarSchema(Vertx vertx, RestoreJobsSchema restoreJobsSchema, RestoreSlicesSchema restoreSlicesSchema, RestoreRangesSchema restoreRangesSchema, + SidecarRolePermissionsSchema sidecarRolePermissionsSchema, SystemAuthSchema systemAuthSchema, SidecarLeaseSchema sidecarLeaseSchema, SidecarMetrics metrics, @@ -640,6 +764,7 @@ public SidecarSchema sidecarSchema(Vertx vertx, sidecarInternalKeyspace.registerTableSchema(restoreJobsSchema); sidecarInternalKeyspace.registerTableSchema(restoreSlicesSchema); sidecarInternalKeyspace.registerTableSchema(restoreRangesSchema); + sidecarInternalKeyspace.registerTableSchema(sidecarRolePermissionsSchema); sidecarInternalKeyspace.registerTableSchema(systemAuthSchema); sidecarInternalKeyspace.registerTableSchema(sidecarLeaseSchema); SchemaMetrics schemaMetrics = metrics.server().schema(); diff --git a/server/src/main/java/org/apache/cassandra/sidecar/server/SidecarServerEvents.java b/server/src/main/java/org/apache/cassandra/sidecar/server/SidecarServerEvents.java index 42a3844d9..ceaf52486 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/server/SidecarServerEvents.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/server/SidecarServerEvents.java @@ -64,6 +64,12 @@ public enum SidecarServerEvents */ ON_CASSANDRA_CQL_DISCONNECTED, + /** + * The {@link io.vertx.core.eventbus.EventBus} address where events will be published when {@link com.datastax.driver.core.Cluster} is closed. + * The event contains null message. The instance identifier will be passed as part of the message. + */ + ON_CASSANDRA_DRIVER_CLOSED, + /** * The {@link io.vertx.core.eventbus.EventBus} address where events will be published when all CQL connections * for the Sidecar-managed Cassandra instances are available. diff --git a/server/src/main/java/org/apache/cassandra/sidecar/utils/AuthUtils.java b/server/src/main/java/org/apache/cassandra/sidecar/utils/AuthUtils.java new file mode 100644 index 000000000..44750fca4 --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/utils/AuthUtils.java @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.sidecar.utils; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import io.netty.handler.codec.http.HttpResponseStatus; +import io.vertx.ext.auth.User; +import io.vertx.ext.web.handler.HttpException; +import org.apache.cassandra.sidecar.acl.authorization.Action; +import org.apache.cassandra.sidecar.acl.authorization.StandardAction; +import org.apache.cassandra.sidecar.acl.authorization.WildcardAction; + +import static org.apache.cassandra.sidecar.acl.authorization.WildcardAction.WILDCARD_PART_DIVIDER_TOKEN; +import static org.apache.cassandra.sidecar.acl.authorization.WildcardAction.WILDCARD_TOKEN; + +/** + * Class with utility methods for Authentication and Authorization. + */ +public class AuthUtils +{ + /** + * Extracts a list of identities a user holds from their principal. + * + * @param user User object in Vertx + * @return extracted identities of user + */ + public static List extractIdentities(User user) + { + validatePrincipal(user); + + return Optional.ofNullable(user.principal().getString("identity")) + .map(Collections::singletonList) + .orElseGet(() -> Arrays.asList(user.principal() + .getString("identities") + .split(","))); + } + + /** + * @return an instance of {@link Action} given the name + */ + public static Action actionFromName(String name) + { + if (name == null) + { + throw new IllegalArgumentException("Name can not be null"); + } + + boolean isWildCard = name.equals(WILDCARD_TOKEN) || name.contains(WILDCARD_PART_DIVIDER_TOKEN); + if (isWildCard) + { + return new WildcardAction(name); + } + return new StandardAction(name); + } + + private static void validatePrincipal(User user) + { + if (user.principal() == null) + { + throw new HttpException(HttpResponseStatus.FORBIDDEN.code(), "User principal empty"); + } + + if (!user.principal().containsKey("identity") && !user.principal().containsKey("identities")) + { + throw new HttpException(HttpResponseStatus.FORBIDDEN.code(), "No valid identity found for authorizing"); + } + } +} diff --git a/server/src/test/integration/org/apache/cassandra/sidecar/acl/RoleBasedAuthorizationIntegrationTest.java b/server/src/test/integration/org/apache/cassandra/sidecar/acl/RoleBasedAuthorizationIntegrationTest.java new file mode 100644 index 000000000..13f68407b --- /dev/null +++ b/server/src/test/integration/org/apache/cassandra/sidecar/acl/RoleBasedAuthorizationIntegrationTest.java @@ -0,0 +1,428 @@ +/* + * 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.acl; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +import com.google.common.util.concurrent.Uninterruptibles; +import org.junit.jupiter.api.extension.ExtendWith; + +import com.datastax.driver.core.Session; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.vertx.core.Future; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpMethod; +import io.vertx.ext.web.client.HttpResponse; +import io.vertx.ext.web.client.WebClient; +import io.vertx.junit5.Checkpoint; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; +import org.apache.cassandra.sidecar.config.SslConfiguration; +import org.apache.cassandra.sidecar.config.yaml.KeyStoreConfigurationImpl; +import org.apache.cassandra.sidecar.config.yaml.SslConfigurationImpl; +import org.apache.cassandra.sidecar.testing.IntegrationTestBase; +import org.apache.cassandra.testing.CassandraIntegrationTest; +import org.apache.cassandra.testing.ConfigurableCassandraTestContext; + +import static org.apache.cassandra.sidecar.testing.IntegrationTestModule.ADMIN_IDENTITY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +/** + * Test for role based access control in Sidecar + */ +@ExtendWith(VertxExtension.class) +public class RoleBasedAuthorizationIntegrationTest extends IntegrationTestBase +{ + private static final int MIN_VERSION_WITH_MTLS = 5; + + @CassandraIntegrationTest(buildCluster = false) + void testForAdmin(VertxTestContext context, ConfigurableCassandraTestContext cassandraContext) throws Exception + { + // starts cluster for 5.0 and above version test + startClusterWithMtlsAndAuthorizer(cassandraContext); + + String keyspaceSchemaRoute = String.format("/api/v1/keyspaces/%s/schema", "sample_keyspace"); + // uses client keystore with admin identity. Admins bypass authorization checks + verifyAccess(context, HttpMethod.GET, keyspaceSchemaRoute, clientKeystorePath); + } + + @CassandraIntegrationTest(buildCluster = false) + void testForSuperUser(VertxTestContext context, ConfigurableCassandraTestContext cassandraContext) throws Exception + { + // starts cluster for 5.0 and above version test + startClusterWithMtlsAndAuthorizer(cassandraContext); + + createRole("test_role", true); + insertIdentityRole("spiffe://cassandra/sidecar/test_user", "test_role"); + + String keyspaceSchemaRoute = String.format("/api/v1/keyspaces/%s/schema", "sample_keyspace"); + // uses client keystore with superuser identity + Path clientKeystorePath = clientKeystorePath("spiffe://cassandra/sidecar/test_user"); + verifyAccess(context, HttpMethod.GET, keyspaceSchemaRoute, clientKeystorePath); + } + + @CassandraIntegrationTest(buildCluster = false) + void testForNonAdmin(VertxTestContext context, ConfigurableCassandraTestContext cassandraContext) throws Exception + { + // starts cluster for 5.0 and above version test + startClusterWithMtlsAndAuthorizer(cassandraContext); + + createRole("test_role", false); + insertIdentityRole("spiffe://cassandra/sidecar/test_user", "test_role"); + + // grant permission for not non super user + grantKeyspacePermission("sample_keyspace", "test_role"); + String keyspaceSchemaRoute = String.format("/api/v1/keyspaces/%s/schema", "sample_keyspace"); + Path clientKeystorePath = clientKeystorePath("spiffe://cassandra/sidecar/test_user"); + verifyAccess(context, HttpMethod.GET, keyspaceSchemaRoute, clientKeystorePath); + } + + @CassandraIntegrationTest(buildCluster = false) + void testGrantingForTable(VertxTestContext context, ConfigurableCassandraTestContext cassandraContext) + throws Exception + { + // starts cluster for 5.0 and above version test + startClusterWithMtlsAndAuthorizer(cassandraContext); + + createRole("test_role", false); + insertIdentityRole("spiffe://cassandra/sidecar/test_user", "test_role"); + + grantSidecarPermission("test_role", "data/sample_keyspace/sample_table", "CREATE:SNAPSHOT"); + + String createSnapshotRoute = String.format("/api/v1/keyspaces/%s/tables/%s/snapshots/my-snapshot", + "sample_keyspace", "sample_table"); + Path clientKeystorePath = clientKeystorePath("spiffe://cassandra/sidecar/test_user"); + + Checkpoint checkpoint = context.checkpoint(2); + + // CREATE:SNAPSHOT permission granted for data/sample_keyspace/sample_table + verifyAccess(context, checkpoint, HttpMethod.PUT, createSnapshotRoute, clientKeystorePath, false); + + // DELETE:SNAPSHOT permission not granted for data/sample_keyspace/sample_table + verifyAccess(context, checkpoint, HttpMethod.DELETE, createSnapshotRoute, clientKeystorePath, true); + } + + @CassandraIntegrationTest(buildCluster = false) + void testEndpointWithOrAuthorization(VertxTestContext context, ConfigurableCassandraTestContext cassandraContext) + throws Exception + { + // starts cluster for 5.0 and above version test + startClusterWithMtlsAndAuthorizer(cassandraContext); + + createRole("test_role", false); + insertIdentityRole("spiffe://cassandra/sidecar/test_user", "test_role"); + + grantSidecarPermission("test_role", "data/sample_keyspace", "VIEW:*"); + String keyspaceSchemaRoute = String.format("/api/v1/keyspaces/%s/schema", "sample_keyspace"); + Path clientKeystorePath = clientKeystorePath("spiffe://cassandra/sidecar/test_user"); + + // schema endpoint for keyspaces accepts CREATE, ALTER, DROP or DESCRIBE cassandra permissions. + // cassandra permission for test_role on sample_keyspace not granted, sidecar permission VIEW:* is used to + // grant access + verifyAccess(context, HttpMethod.GET, keyspaceSchemaRoute, clientKeystorePath); + } + + @CassandraIntegrationTest(buildCluster = false) + void testWildcardActionForAllTargets(VertxTestContext context, ConfigurableCassandraTestContext cassandraContext) + throws Exception + { + // starts cluster for 5.0 and above version test + startClusterWithMtlsAndAuthorizer(cassandraContext); + + createRole("test_role", false); + insertIdentityRole("spiffe://cassandra/sidecar/test_user", "test_role"); + + // VIEW action allowed across targets for same resource. VIEW:* for cluster resource allows VIEW:SCHEMA, + // VIEW:CDC, VIEW:CLUSTER etc + grantSidecarPermission("test_role", "cluster", "VIEW:*"); + + String timeSkewRoute = "/api/v1/time-skew"; + Path clientKeystorePath = clientKeystorePath("spiffe://cassandra/sidecar/test_user"); + + Checkpoint checkpoint = context.checkpoint(4); + + // Uses sidecar permission VIEW:* added + verifyAccess(context, checkpoint, HttpMethod.GET, timeSkewRoute, clientKeystorePath, false); + + String schemaRoute = "/api/v1/cassandra/schema"; + verifyAccess(context, checkpoint, HttpMethod.GET, schemaRoute, clientKeystorePath, false); + + String ringRoute = "/api/v1/cassandra/ring"; + // Allows VIEW:CLUSTER too + verifyAccess(context, checkpoint, HttpMethod.GET, ringRoute, clientKeystorePath, false); + + String keyspaceSchemaRoute = String.format("/api/v1/keyspaces/%s/schema", "sample_keyspace"); + // Does not allow finding schema for keyspace, keyspace schema route requires access specific to + // data/sample_keyspace resource + verifyAccess(context, checkpoint, HttpMethod.GET, keyspaceSchemaRoute, clientKeystorePath, true); + } + + @CassandraIntegrationTest(buildCluster = false) + void testResourceWideActions(VertxTestContext context, ConfigurableCassandraTestContext cassandraContext) throws Exception + { + // starts cluster for 5.0 and above version test + startClusterWithMtlsAndAuthorizer(cassandraContext); + + createRole("test_role", false); + insertIdentityRole("spiffe://cassandra/sidecar/test_user", "test_role"); + + grantSidecarPermission("test_role", "data/sample_keyspace", "*:*"); + + String keyspaceSchemaRoute = String.format("/api/v1/keyspaces/%s/schema", "sample_keyspace"); + Path clientKeystorePath = clientKeystorePath("spiffe://cassandra/sidecar/test_user"); + + Checkpoint checkpoint = context.checkpoint(3); + + // *:* permission across resource data/test_keyspace allows all possible actions across all possible targets, + // Such as VIEW:SCHEMA, VIEW:CLUSTER etc. + verifyAccess(context, checkpoint, HttpMethod.GET, keyspaceSchemaRoute, clientKeystorePath, false); + + String keyspaceRingRoute = String.format("/api/v1/cassandra/ring/keyspaces/%s", "sample_keyspace"); + // VIEW:CLUSTER granted + verifyAccess(context, checkpoint, HttpMethod.GET, keyspaceRingRoute, clientKeystorePath, false); + + String viewSnapshotRoute = String.format("/api/v1/keyspaces/%s/tables/%s/snapshots/%s", + "sample_keyspace", "sample_table", "sample_snapshot"); + // does not allow VIEW:SNAPSHOT which requires table resource too + verifyAccess(context, checkpoint, HttpMethod.GET, viewSnapshotRoute, clientKeystorePath, true); + } + + @CassandraIntegrationTest(buildCluster = false) + void testAllWildcardActionsForTarget(VertxTestContext context, ConfigurableCassandraTestContext cassandraContext) + throws Exception + { + // starts cluster for 5.0 and above version test + startClusterWithMtlsAndAuthorizer(cassandraContext); + + createRole("test_role", false); + insertIdentityRole("spiffe://cassandra/sidecar/test_user", "test_role"); + + grantSidecarPermission("test_role", "data/sample_keyspace/sample_table", "*:SNAPSHOT"); + + String createSnapshotRoute = String.format("/api/v1/keyspaces/%s/tables/%s/snapshots/my-snapshot", + "sample_keyspace", "sample_table"); + Path clientKeystorePath = clientKeystorePath("spiffe://cassandra/sidecar/test_user"); + + Checkpoint checkpoint = context.checkpoint(2); + + // *:SNAPSHOT permission across data/sample_resource/sample_table allows all possible actions for SNAPSHOT target + // such as CREATE:SNAPSHOT, VIEW:SNAPSHOT, DELETE:SNAPSHOT. Does not allow STREAM:SSTABLE or other actions + verifyAccess(context, checkpoint, HttpMethod.PUT, createSnapshotRoute, clientKeystorePath, false); + + String streamSSTableRoute = String.format("/api/v1/keyspaces/%s/tables/%s/snapshots/%s/components/%s", + "sample_keyspace", "sample_table", "my-snapshot", "nc-1-big-Data.db"); + verifyAccess(context, checkpoint, HttpMethod.GET, streamSSTableRoute, clientKeystorePath, true); + } + + @CassandraIntegrationTest(buildCluster = false) + void testEndpointRequiringMultipleActions(VertxTestContext context, ConfigurableCassandraTestContext cassandraContext) + throws Exception + { + // starts cluster for 5.0 and above version test + startClusterWithMtlsAndAuthorizer(cassandraContext); + + createRole("test_role", false); + insertIdentityRole("spiffe://cassandra/sidecar/test_user", "test_role"); + + grantSidecarPermission("test_role", "data/sample_keyspace/sample_table", "CREATE:SNAPSHOT"); + + String createSnapshotRoute = String.format("/api/v1/keyspaces/%s/tables/%s/snapshots/my-snapshot", + "sample_keyspace", "sample_table"); + Path clientKeystorePath = clientKeystorePath("spiffe://cassandra/sidecar/test_user"); + + String streamRoute + = String.format("/api/v1/keyspaces/%s/tables/%s/snapshots/%s/components/%s", + "sample_keyspace", "sample_table", "my-snapshot", "nc-1-big-Data.db"); + + // CREATE:SNAPSHOT permission granted for data/sample_keyspace/sample_table + WebClient client = createClient(clientKeystorePath, truststorePath); + client.put(server.actualPort(), "127.0.0.1", createSnapshotRoute) + .send() + .compose(createResp -> { + assertThat(createResp.statusCode()).isEqualTo(HttpResponseStatus.OK.code()); + + // grant sidecar permission for streaming + updateSidecarPermission("test_role", "data/sample_keyspace/sample_table", "STREAM:SSTABLE"); + + // wait for cache refresh + Uninterruptibles.sleepUninterruptibly(3000, TimeUnit.MILLISECONDS); + + // STREAM SSTable request requires both Sidecar STREAM:SSTABLE permission and Cassandra's SELECT + // permission on a table it accesses data. + return streamRequest(client, streamRoute); + }) + .compose(deniedStreamResp -> { + // access denied without SELECT permission + assertThat(deniedStreamResp.statusCode()).isEqualTo(HttpResponseStatus.FORBIDDEN.code()); + + // grant SELECT permission with cassandra role + grantTablePermission("sample_keyspace", "sample_table", "test_role"); + + // wait for cache refresh + Uninterruptibles.sleepUninterruptibly(3000, TimeUnit.MILLISECONDS); + + return streamRequest(client, streamRoute); + }) + .onFailure(context::failNow) + .onComplete(acceptedStreamResp -> { + if (acceptedStreamResp.cause() != null) + { + context.failNow(acceptedStreamResp.cause()); + return; + } + + // request goes through with both permissions granted + assertThat(acceptedStreamResp.result().statusCode()).isEqualTo(HttpResponseStatus.OK.code()); + context.completeNow(); + }); + } + + private void startClusterWithMtlsAndAuthorizer(ConfigurableCassandraTestContext cassandraContext) throws Exception + { + // mTLS authentication was added in Cassandra starting 5.0 version + assumeThat(cassandraContext.version.major) + .withFailMessage("mTLS authentication is not supported in 4.0 Cassandra version") + .isGreaterThanOrEqualTo(MIN_VERSION_WITH_MTLS); + + cassandraContext.configureAndStartCluster(builder -> { + builder.appendConfig(config -> config.set("authenticator.class_name", + "org.apache.cassandra.auth.MutualTlsWithPasswordFallbackAuthenticator") + .set("authenticator.parameters", + Collections.singletonMap("validator_class_name", "org.apache.cassandra.auth.SpiffeCertificateValidator")) + .set("role_manager", "CassandraRoleManager") + .set("authorizer", "CassandraAuthorizer") + .set("client_encryption_options.enabled", "true") + .set("client_encryption_options.optional", "true") + .set("client_encryption_options.require_client_auth", "true") + .set("client_encryption_options.require_endpoint_verification", "false") + .set("client_encryption_options.keystore", serverKeystorePath.toAbsolutePath().toString()) + .set("client_encryption_options.keystore_password", serverKeystorePassword) + .set("client_encryption_options.truststore", truststorePath.toAbsolutePath().toString()) + .set("client_encryption_options.truststore_password", truststorePassword)); + }); + waitForSchemaReady(30, TimeUnit.SECONDS); + + // required for authentication of sidecar requests to Cassandra. Only superusers can grant permissions + insertIdentityRole(ADMIN_IDENTITY, "cassandra"); + createKeyspaceTable(); + + // Add keystore for Sidecar + sidecarTestContext.setSslConfiguration(sslConfigWithKeystoreTruststore()); + Thread.sleep(2000); + } + + private void createKeyspaceTable() + { + createKeyspace("sample_keyspace"); + createTable("sample_keyspace", "sample_table"); + } + + private void createRole(String role, boolean superUser) + { + Session session = maybeGetSession(); + session.execute("CREATE ROLE " + role + " WITH PASSWORD = 'password' AND SUPERUSER = " + superUser + " AND LOGIN = true;"); + } + + private void insertIdentityRole(String identity, String role) + { + Session session = maybeGetSession(); + session.execute("INSERT INTO system_auth.identity_to_role (identity, role) VALUES (\'" + identity + "\',\'" + role + "\');"); + } + + private void createKeyspace(String keyspace) + { + Session session = maybeGetSession(); + session.execute("CREATE KEYSPACE IF NOT EXISTS " + keyspace + " WITH REPLICATION = {'class':'SimpleStrategy', 'replication_factor':'3'}"); + } + + private void createTable(String keyspace, String table) + { + Session session = maybeGetSession(); + session.execute(String.format("CREATE TABLE %s.%s (a int, b text, PRIMARY KEY (a));", keyspace, table)); + session.execute("INSERT INTO " + keyspace + "." + table + " (a, b) VALUES (1, 'text');"); + } + + private void grantKeyspacePermission(String keyspace, String role) + { + Session session = maybeGetSession(); + session.execute("GRANT ALL PERMISSIONS ON KEYSPACE " + keyspace + " TO " + role); + } + + private void grantTablePermission(String keyspace, String table, String role) + { + Session session = maybeGetSession(); + session.execute("GRANT ALL PERMISSIONS ON " + keyspace + "." + table + " TO " + role); + } + + private void grantSidecarPermission(String role, String resource, String permission) + { + Session session = maybeGetSession(); + session.execute(String.format("INSERT INTO sidecar_internal.role_permissions_v1 (role, resource, permissions) " + + "VALUES ('%s', '%s', {'%s'})", role, resource, permission)); + } + + private void updateSidecarPermission(String role, String resource, String permission) + { + Session session = maybeGetSession(); + session.execute(String.format("UPDATE sidecar_internal.role_permissions_v1 SET permissions = permissions + {'%s'} " + + "where role = '%s' and resource = '%s'", permission, role, resource)); + } + + private SslConfiguration sslConfigWithKeystoreTruststore() + { + return SslConfigurationImpl + .builder() + .enabled(true) + .keystore(new KeyStoreConfigurationImpl(clientKeystorePath.toAbsolutePath().toString(), clientKeystorePassword, "PKCS12")) + .truststore(new KeyStoreConfigurationImpl(truststorePath.toAbsolutePath().toString(), truststorePassword, "PKCS12")) + .build(); + } + + private void verifyAccess(VertxTestContext context, HttpMethod method, String testRoute, Path clientKeystorePath) + { + Checkpoint checkpoint = context.checkpoint(); + verifyAccess(context, checkpoint, method, testRoute, clientKeystorePath, false); + } + + private void verifyAccess(VertxTestContext context, Checkpoint checkpoint, HttpMethod method, + String testRoute, Path clientKeystorePath, boolean expectForbidden) + { + WebClient client = createClient(clientKeystorePath, truststorePath); + client.request(method, server.actualPort(), "127.0.0.1", testRoute) + .send(context.succeeding(response -> { + context.verify(() -> { + if (expectForbidden) + { + assertThat(response.statusCode()).isEqualTo(HttpResponseStatus.FORBIDDEN.code()); + return; + } + assertThat(response.statusCode()).isEqualTo(HttpResponseStatus.OK.code()); + }); + checkpoint.flag(); + })); + } + + private Future> streamRequest(WebClient client, String route) + { + return client.get(server.actualPort(), "127.0.0.1", route).send(); + } +} diff --git a/server/src/test/integration/org/apache/cassandra/sidecar/cluster/locator/CqlSessionProviderIntegrationTest.java b/server/src/test/integration/org/apache/cassandra/sidecar/cluster/locator/CqlSessionProviderIntegrationTest.java index 9cdca62dd..bafd9909f 100644 --- a/server/src/test/integration/org/apache/cassandra/sidecar/cluster/locator/CqlSessionProviderIntegrationTest.java +++ b/server/src/test/integration/org/apache/cassandra/sidecar/cluster/locator/CqlSessionProviderIntegrationTest.java @@ -190,4 +190,3 @@ private void insertIdentityRole(String identity, String role) session.execute("INSERT INTO system_auth.identity_to_role (identity, role) VALUES (\'" + identity + "\',\'" + role + "\');"); } } - diff --git a/server/src/test/integration/org/apache/cassandra/sidecar/coordination/ClusterLeaseClaimTaskIntegrationTest.java b/server/src/test/integration/org/apache/cassandra/sidecar/coordination/ClusterLeaseClaimTaskIntegrationTest.java index d7a2425ff..bcd103ee9 100644 --- a/server/src/test/integration/org/apache/cassandra/sidecar/coordination/ClusterLeaseClaimTaskIntegrationTest.java +++ b/server/src/test/integration/org/apache/cassandra/sidecar/coordination/ClusterLeaseClaimTaskIntegrationTest.java @@ -362,7 +362,7 @@ private SidecarMetrics buildMetrics() DisconnectableCQLSessionProvider buildCqlSession(List address) { CQLSessionProvider sessionProvider = - new CQLSessionProviderImpl(address, address, 500, null, 0, SharedExecutorNettyOptions.INSTANCE); + new CQLSessionProviderImpl(vertx, address, address, 500, null, 0, SharedExecutorNettyOptions.INSTANCE); sessionProviderList.add(sessionProvider); return new DisconnectableCQLSessionProvider(sessionProvider); } diff --git a/server/src/test/integration/org/apache/cassandra/sidecar/coordination/MostReplicatedKeyspaceTokenZeroElectorateMembershipIntegrationTest.java b/server/src/test/integration/org/apache/cassandra/sidecar/coordination/MostReplicatedKeyspaceTokenZeroElectorateMembershipIntegrationTest.java index 1f1eb342b..3b6af2bbe 100644 --- a/server/src/test/integration/org/apache/cassandra/sidecar/coordination/MostReplicatedKeyspaceTokenZeroElectorateMembershipIntegrationTest.java +++ b/server/src/test/integration/org/apache/cassandra/sidecar/coordination/MostReplicatedKeyspaceTokenZeroElectorateMembershipIntegrationTest.java @@ -167,7 +167,7 @@ List buildElectorateMembers { List address = buildContactList(instance); CQLSessionProvider sessionProvider = - new CQLSessionProviderImpl(address, address, 500, instance.config().localDatacenter(), 0, SharedExecutorNettyOptions.INSTANCE); + new CQLSessionProviderImpl(vertx, address, address, 500, instance.config().localDatacenter(), 0, SharedExecutorNettyOptions.INSTANCE); InstancesConfig instancesConfig = buildInstancesConfig(instance, sessionProvider, metricRegistryProvider); result.add(new MostReplicatedKeyspaceTokenZeroElectorateMembership(instancesConfig, sessionProvider, CONFIG)); } diff --git a/server/src/test/integration/org/apache/cassandra/sidecar/db/ReprepareStatementsOnReconnectionIntTest.java b/server/src/test/integration/org/apache/cassandra/sidecar/db/ReprepareStatementsOnReconnectionIntTest.java new file mode 100644 index 000000000..4d34927a2 --- /dev/null +++ b/server/src/test/integration/org/apache/cassandra/sidecar/db/ReprepareStatementsOnReconnectionIntTest.java @@ -0,0 +1,47 @@ +/* + * 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.db; + +import java.util.concurrent.TimeUnit; + +import com.datastax.driver.core.utils.UUIDs; +import org.apache.cassandra.sidecar.common.server.cluster.locator.TokenRange; +import org.apache.cassandra.sidecar.testing.IntegrationTestBase; +import org.apache.cassandra.testing.CassandraIntegrationTest; + +import static org.assertj.core.api.Assertions.assertThatNoException; + +class ReprepareStatementsOnReconnectionIntTest extends IntegrationTestBase +{ + @CassandraIntegrationTest + void testStatementsAreRepreparedOnReconnection() + { + RestoreSliceDatabaseAccessor accessor = injector.getInstance(RestoreSliceDatabaseAccessor.class); + RestoreJob testJob = RestoreJobTest.createNewTestingJob(UUIDs.timeBased()); + TokenRange range = new TokenRange(0, 10); + + waitForSchemaReady(10, TimeUnit.SECONDS); + assertThatNoException().isThrownBy(() -> accessor.selectByJobByBucketByTokenRange(testJob, (short) 0, range)); + + closeNativeThenReconnect(); + + waitForSchemaReady(10, TimeUnit.SECONDS); + assertThatNoException().isThrownBy(() -> accessor.selectByJobByBucketByTokenRange(testJob, (short) 0, range)); + } +} diff --git a/server/src/test/integration/org/apache/cassandra/sidecar/testing/CassandraSidecarTestContext.java b/server/src/test/integration/org/apache/cassandra/sidecar/testing/CassandraSidecarTestContext.java index 79f0b8ec5..4d4182d48 100644 --- a/server/src/test/integration/org/apache/cassandra/sidecar/testing/CassandraSidecarTestContext.java +++ b/server/src/test/integration/org/apache/cassandra/sidecar/testing/CassandraSidecarTestContext.java @@ -187,7 +187,7 @@ public InstancesConfig instancesConfig() { if (instancesConfig == null) { - refreshInstancesConfig(); + return refreshInstancesConfig(); } return this.instancesConfig; } @@ -196,6 +196,7 @@ public InstancesConfig refreshInstancesConfig() { // clean-up any open sessions or client resources close(); + closeSessionProvider(); setInstancesConfig(); return this.instancesConfig; } @@ -250,7 +251,7 @@ private InstancesConfig buildInstancesConfig(CassandraVersionProvider versionPro jmxClients = new ArrayList<>(); List configs = buildInstanceConfigs(cluster); List addresses = buildContactList(configs); - sessionProvider = new CQLSessionProviderImpl(addresses, addresses, 500, null, + sessionProvider = new CQLSessionProviderImpl(vertx, addresses, addresses, 500, null, 0, username, password, sslConfiguration, SharedExecutorNettyOptions.INSTANCE); for (int i = 0; i < configs.size(); i++) diff --git a/server/src/test/integration/org/apache/cassandra/sidecar/testing/IntegrationTestBase.java b/server/src/test/integration/org/apache/cassandra/sidecar/testing/IntegrationTestBase.java index 2e18dfbc0..c9f338a2c 100644 --- a/server/src/test/integration/org/apache/cassandra/sidecar/testing/IntegrationTestBase.java +++ b/server/src/test/integration/org/apache/cassandra/sidecar/testing/IntegrationTestBase.java @@ -177,7 +177,7 @@ protected void waitForSchemaReady(long timeout, TimeUnit timeUnit) CountDownLatch latch = new CountDownLatch(1); vertx.eventBus() .localConsumer(SidecarServerEvents.ON_SIDECAR_SCHEMA_INITIALIZED.address(), msg -> latch.countDown()); - awaitLatchOrTimeout(latch, timeout, timeUnit); + awaitLatchOrTimeout(latch, timeout, timeUnit, "waitForSchemaInitialized"); assertThat(latch.getCount()).describedAs("Sidecar schema not initialized").isZero(); } @@ -241,7 +241,7 @@ protected void createTestKeyspace(Map rf) { try { - sidecarTestContext.refreshInstancesConfig(); + sidecarTestContext.instancesConfig(); Session session = maybeGetSession(); @@ -263,6 +263,12 @@ protected void createTestKeyspace(Map rf) throw rte; } + protected void closeNativeThenReconnect() + { + sidecarTestContext.close(); + maybeGetSession(); + } + private String generateRfString(Map dcToRf) { return dcToRf.entrySet().stream().map(e -> String.format("'%s':%d", e.getKey(), e.getValue())) @@ -304,11 +310,6 @@ protected static void awaitLatchOrTimeout(CountDownLatch latch, long duration, T .isTrue(); } - protected static void awaitLatchOrTimeout(CountDownLatch latch, long duration, TimeUnit timeUnit) - { - awaitLatchOrTimeout(latch, duration, timeUnit, null); - } - protected Session maybeGetSession() { Session session = sidecarTestContext.session(); diff --git a/server/src/test/integration/org/apache/cassandra/sidecar/testing/IntegrationTestModule.java b/server/src/test/integration/org/apache/cassandra/sidecar/testing/IntegrationTestModule.java index 341f16354..fa23f0e2f 100644 --- a/server/src/test/integration/org/apache/cassandra/sidecar/testing/IntegrationTestModule.java +++ b/server/src/test/integration/org/apache/cassandra/sidecar/testing/IntegrationTestModule.java @@ -165,10 +165,14 @@ private AccessControlConfiguration accessControlConfiguration() ParameterizedClassConfiguration mTLSConfig = new ParameterizedClassConfigurationImpl("org.apache.cassandra.sidecar.acl.authentication.MutualTlsAuthenticationHandlerFactory", params); + ParameterizedClassConfiguration rbacConfig + = new ParameterizedClassConfigurationImpl("org.apache.cassandra.sidecar.acl.authorization.RoleBasedAuthorizationProvider", + Collections.emptyMap()); return new AccessControlConfigurationImpl(true, Collections.singletonList(mTLSConfig), + rbacConfig, Collections.singleton(ADMIN_IDENTITY), - new CacheConfigurationImpl()); + new CacheConfigurationImpl(1000, 100, true, 5, 1000)); } class WrapperInstancesConfig implements InstancesConfig diff --git a/server/src/test/java/org/apache/cassandra/sidecar/acl/authentication/MutualTLSAuthenticationHandlerTest.java b/server/src/test/java/org/apache/cassandra/sidecar/acl/authentication/MutualTLSAuthenticationHandlerTest.java index dd1ac1046..b8e5e2525 100644 --- a/server/src/test/java/org/apache/cassandra/sidecar/acl/authentication/MutualTLSAuthenticationHandlerTest.java +++ b/server/src/test/java/org/apache/cassandra/sidecar/acl/authentication/MutualTLSAuthenticationHandlerTest.java @@ -308,7 +308,12 @@ public SidecarConfigurationImpl abstractConfig() AccessControlConfiguration accessControlConfiguration - = new AccessControlConfigurationImpl(true, authenticatorsConfiguration(), Collections.singleton(ADMIN_IDENTITY), new CacheConfigurationImpl()); + = new AccessControlConfigurationImpl(true, + authenticatorsConfiguration(), + new ParameterizedClassConfigurationImpl("org.apache.cassandra.sidecar.acl.authorization.AllowAllAuthorizationProvider", + Collections.emptyMap()), + Collections.singleton(ADMIN_IDENTITY), + new CacheConfigurationImpl()); return super.abstractConfig(sslConfiguration, accessControlConfiguration); } diff --git a/server/src/test/java/org/apache/cassandra/sidecar/acl/authorization/ActionTest.java b/server/src/test/java/org/apache/cassandra/sidecar/acl/authorization/ActionTest.java new file mode 100644 index 000000000..da461315f --- /dev/null +++ b/server/src/test/java/org/apache/cassandra/sidecar/acl/authorization/ActionTest.java @@ -0,0 +1,95 @@ +/* + * 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.acl.authorization; + +import org.junit.jupiter.api.Test; + +import io.vertx.ext.auth.authorization.PermissionBasedAuthorization; +import io.vertx.ext.auth.authorization.WildcardPermissionBasedAuthorization; +import org.apache.cassandra.sidecar.exceptions.ConfigurationException; + +import static org.apache.cassandra.sidecar.utils.AuthUtils.actionFromName; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Test for {@link Action} + */ +class ActionTest +{ + @Test + void testValidActions() + { + assertThat(actionFromName("CREATE_SNAPSHOT")).isInstanceOf(StandardAction.class); + assertThat(actionFromName("OPERATE")).isInstanceOf(StandardAction.class); + assertThat(actionFromName("CREATESNAPSHOT")).isInstanceOf(StandardAction.class); + assertThat(actionFromName("CREATE:SNAPSHOT")).isInstanceOf(WildcardAction.class); + assertThat(actionFromName("*:SNAPSHOT")).isInstanceOf(WildcardAction.class); + assertThat(actionFromName("STREAM:*")).isInstanceOf(WildcardAction.class); + assertThat(actionFromName("*")).isInstanceOf(WildcardAction.class); + assertThat(actionFromName("*:*")).isInstanceOf(WildcardAction.class); + assertThat(actionFromName("CREATE:SNAPSHOT:*")).isInstanceOf(WildcardAction.class); + } + + @Test + void testInvalidActions() + { + assertThatThrownBy(() -> actionFromName("")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> actionFromName(null)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void testToAuthorizationWithResource() + { + String expectedResource = VariableAwareResource.DATA_WITH_KEYSPACE_TABLE.resource(); + PermissionBasedAuthorization authorization + = (PermissionBasedAuthorization) actionFromName("CREATESNAPSHOT").toAuthorization(expectedResource); + assertThat(authorization.getResource()).isEqualTo(expectedResource); + WildcardPermissionBasedAuthorization wildcardAuthorization + = (WildcardPermissionBasedAuthorization) actionFromName("CREATE:SNAPSHOT").toAuthorization(expectedResource); + assertThat(wildcardAuthorization.getResource()).isEqualTo(expectedResource); + } + + @Test + void testToAuthorizationWithEmptyResource() + { + PermissionBasedAuthorization authorization + = (PermissionBasedAuthorization) actionFromName("CREATESNAPSHOT").toAuthorization(""); + assertThat(authorization.getResource()).isNull(); + WildcardPermissionBasedAuthorization wildcardAuthorization + = (WildcardPermissionBasedAuthorization) actionFromName("CREATE:SNAPSHOT").toAuthorization(""); + assertThat(wildcardAuthorization.getResource()).isNull(); + } + + @Test + void testInvalidWildcardActions() + { + assertThatThrownBy(() -> new WildcardAction("CREATE")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Wildcard actions must either have wildcard token * or must have wildcard parts"); + + assertThatThrownBy(() -> new WildcardAction(":")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Wildcard action parts can not be empty"); + + assertThatThrownBy(() -> new WildcardAction("::")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Wildcard action parts can not be empty"); + } +} diff --git a/server/src/test/java/org/apache/cassandra/sidecar/acl/authorization/AdminIdentityResolverTest.java b/server/src/test/java/org/apache/cassandra/sidecar/acl/authorization/AdminIdentityResolverTest.java new file mode 100644 index 000000000..211cc5102 --- /dev/null +++ b/server/src/test/java/org/apache/cassandra/sidecar/acl/authorization/AdminIdentityResolverTest.java @@ -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. + */ + +package org.apache.cassandra.sidecar.acl.authorization; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import org.apache.cassandra.sidecar.acl.IdentityToRoleCache; +import org.apache.cassandra.sidecar.config.AccessControlConfiguration; +import org.apache.cassandra.sidecar.config.SidecarConfiguration; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Test for {@link AdminIdentityResolver} + */ +class AdminIdentityResolverTest +{ + @Test + void testAdminIdentityFromConfig() + { + IdentityToRoleCache mockIdentityToRoleCache = mock(IdentityToRoleCache.class); + SuperUserCache mockSuperUserCache = mock(SuperUserCache.class); + SidecarConfiguration mockConfig = mock(SidecarConfiguration.class); + AccessControlConfiguration mockAclConfig = mock(AccessControlConfiguration.class); + when(mockAclConfig.adminIdentities()).thenReturn(Collections.singleton("spiffe://cassandra/sidecar/admin")); + when(mockConfig.accessControlConfiguration()).thenReturn(mockAclConfig); + AdminIdentityResolver adminIdentityResolver = new AdminIdentityResolver(mockIdentityToRoleCache, + mockSuperUserCache, + mockConfig); + assertThat(adminIdentityResolver.isAdmin("spiffe://cassandra/sidecar/admin")).isTrue(); + assertThat(adminIdentityResolver.isAdmin("spiffe://cassandra/sidecar/test_user")).isFalse(); + } + + @Test + void testSuperUser() + { + IdentityToRoleCache mockIdentityToRoleCache = mock(IdentityToRoleCache.class); + when(mockIdentityToRoleCache.get("spiffe://cassandra/sidecar/test_user")).thenReturn("test_role"); + SuperUserCache mockSuperUserCache = mock(SuperUserCache.class); + when(mockSuperUserCache.isSuperUser("test_role")).thenReturn(true); + SidecarConfiguration mockConfig = mock(SidecarConfiguration.class); + AccessControlConfiguration mockAclConfig = mock(AccessControlConfiguration.class); + when(mockAclConfig.adminIdentities()).thenReturn(Collections.emptySet()); + when(mockConfig.accessControlConfiguration()).thenReturn(mockAclConfig); + AdminIdentityResolver adminIdentityResolver = new AdminIdentityResolver(mockIdentityToRoleCache, + mockSuperUserCache, + mockConfig); + assertThat(adminIdentityResolver.isAdmin("spiffe://cassandra/sidecar/test_user")).isTrue(); + assertThat(adminIdentityResolver.isAdmin("spiffe://cassandra/sidecar/admin")).isFalse(); + } + + @Test + void testNonAdminIdentity() + { + IdentityToRoleCache mockIdentityToRoleCache = mock(IdentityToRoleCache.class); + SuperUserCache mockSuperUserCache = mock(SuperUserCache.class); + SidecarConfiguration mockConfig = mock(SidecarConfiguration.class); + AccessControlConfiguration mockAclConfig = mock(AccessControlConfiguration.class); + when(mockAclConfig.adminIdentities()).thenReturn(Collections.emptySet()); + when(mockConfig.accessControlConfiguration()).thenReturn(mockAclConfig); + AdminIdentityResolver adminIdentityResolver = new AdminIdentityResolver(mockIdentityToRoleCache, + mockSuperUserCache, + mockConfig); + assertThat(adminIdentityResolver.isAdmin("spiffe://cassandra/sidecar/test_user")).isFalse(); + } +} diff --git a/server/src/test/java/org/apache/cassandra/sidecar/acl/authorization/AllowAllAuthorizationTest.java b/server/src/test/java/org/apache/cassandra/sidecar/acl/authorization/AllowAllAuthorizationTest.java new file mode 100644 index 000000000..4fec3c444 --- /dev/null +++ b/server/src/test/java/org/apache/cassandra/sidecar/acl/authorization/AllowAllAuthorizationTest.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.acl.authorization; + +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import io.vertx.ext.auth.User; +import io.vertx.ext.auth.authorization.Authorization; +import io.vertx.ext.auth.authorization.impl.PermissionBasedAuthorizationImpl; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +/** + * Test for AllowAll authorization setting + */ +@ExtendWith(VertxExtension.class) +class AllowAllAuthorizationTest +{ + @Test + void testAllowAllAuthorizationMatching() + { + Authorization authorization = new PermissionBasedAuthorizationImpl("test_permission"); + User user = User.fromName("test_user"); + assertThat(authorization.match(user)).isFalse(); + user.authorizations().add(AllowAllAuthorizationProvider.INSTANCE.getId(), AllowAllAuthorization.INSTANCE); + assertThat(authorization.match(user)).isTrue(); + assertThat(AllowAllAuthorization.INSTANCE.verify(authorization)).isTrue(); + } + + @Test + void testAuthorizationsWithAllowAllProvider(VertxTestContext testContext) + { + AllowAllAuthorizationProvider authorizationProvider = AllowAllAuthorizationProvider.INSTANCE; + User user = User.fromName("test_user"); + authorizationProvider.getAuthorizations(user) + .onComplete(v -> { + Authorization authorization = new PermissionBasedAuthorizationImpl("test_permission"); + Set found = user.authorizations().get(authorizationProvider.getId()); + assertThat(found.size()).isOne(); + assertThat(found.contains(AllowAllAuthorization.INSTANCE)).isTrue(); + assertThat(found.iterator().next().verify(authorization)).isTrue(); + testContext.completeNow(); + }); + } +} diff --git a/server/src/test/java/org/apache/cassandra/sidecar/acl/authorization/AuthorizationHandlerTest.java b/server/src/test/java/org/apache/cassandra/sidecar/acl/authorization/AuthorizationHandlerTest.java new file mode 100644 index 000000000..a3603c575 --- /dev/null +++ b/server/src/test/java/org/apache/cassandra/sidecar/acl/authorization/AuthorizationHandlerTest.java @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.sidecar.acl.authorization; + +import org.junit.jupiter.api.Test; + +import io.vertx.ext.auth.User; +import io.vertx.ext.auth.authorization.Authorization; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.HttpException; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Test for {@link AuthorizationWithAdminBypassHandler} + */ +class AuthorizationHandlerTest +{ + @Test + void testMissingIdentity() + { + AdminIdentityResolver mockAdminIdentityResolver = mock(AdminIdentityResolver.class); + Authorization mockAuthorization = mock(Authorization.class); + AuthorizationWithAdminBypassHandler authorizationHandler + = new AuthorizationWithAdminBypassHandler(mockAdminIdentityResolver, mockAuthorization); + RoutingContext mockCtx = mock(RoutingContext.class); + User user = User.fromName("test_user"); + when(mockCtx.user()).thenReturn(user); + assertThatThrownBy(() -> authorizationHandler.handle(mockCtx)) + .isInstanceOf(HttpException.class); + } +} diff --git a/server/src/test/java/org/apache/cassandra/sidecar/acl/authorization/RoleAuthorizationsCacheTest.java b/server/src/test/java/org/apache/cassandra/sidecar/acl/authorization/RoleAuthorizationsCacheTest.java new file mode 100644 index 000000000..5cb47c8dc --- /dev/null +++ b/server/src/test/java/org/apache/cassandra/sidecar/acl/authorization/RoleAuthorizationsCacheTest.java @@ -0,0 +1,238 @@ +/* + * 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.acl.authorization; + +import java.util.Collections; + +import com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; +import org.apache.cassandra.sidecar.concurrent.ExecutorPools; +import org.apache.cassandra.sidecar.config.AccessControlConfiguration; +import org.apache.cassandra.sidecar.config.CacheConfiguration; +import org.apache.cassandra.sidecar.config.SchemaKeyspaceConfiguration; +import org.apache.cassandra.sidecar.config.ServiceConfiguration; +import org.apache.cassandra.sidecar.config.SidecarConfiguration; +import org.apache.cassandra.sidecar.db.SidecarPermissionsDatabaseAccessor; +import org.apache.cassandra.sidecar.db.SystemAuthDatabaseAccessor; + +import static org.apache.cassandra.sidecar.ExecutorPoolsHelper.createdSharedTestPool; +import static org.apache.cassandra.sidecar.acl.authorization.RoleAuthorizationsCache.UNIQUE_CACHE_ENTRY; +import static org.apache.cassandra.sidecar.server.SidecarServerEvents.ON_SIDECAR_SCHEMA_INITIALIZED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Test for {@link RoleAuthorizationsCache} + */ +class RoleAuthorizationsCacheTest +{ + Vertx vertx; + ExecutorPools executorPools; + + @BeforeEach + void setup() + { + vertx = Vertx.vertx(); + executorPools = createdSharedTestPool(vertx); + } + + @Test + void testCacheSizeAlwaysOne() throws InterruptedException + { + SystemAuthDatabaseAccessor mockDbAccessor = mock(SystemAuthDatabaseAccessor.class); + when(mockDbAccessor.getAllRolesAndPermissions()) + .thenReturn(Collections.singletonMap("test_role1", Collections.singleton(CassandraActions.SELECT.toAuthorization()))); + SidecarPermissionsDatabaseAccessor mockSidecarPermissionsAccessor = mock(SidecarPermissionsDatabaseAccessor.class); + when(mockSidecarPermissionsAccessor.getAllRolesAndPermissions()) + .thenReturn(Collections.singletonMap("test_role1", Collections.singleton(SidecarActions.CREATE_SNAPSHOT.toAuthorization()))); + SidecarConfiguration mockConfig = mockConfig(); + RoleAuthorizationsCache cache = new RoleAuthorizationsCache(vertx, + executorPools, + mockConfig, + mockDbAccessor, + mockSidecarPermissionsAccessor); + assertThat(cache.getAll().size()).isZero(); + assertThat(cache.getAuthorizations("test_role1").size()).isEqualTo(2); + assertThat(cache.getAll().size()).isOne(); + + when(mockSidecarPermissionsAccessor.getAllRolesAndPermissions()) + .thenReturn(ImmutableMap.of("test_role1", Collections.singleton(SidecarActions.CREATE_SNAPSHOT.toAuthorization()), + "test_role2", Collections.singleton(SidecarActions.STREAM_SSTABLE.toAuthorization()))); + + // wait for cache entries to be refreshed + Thread.sleep(3000); + + // New entries fetched during refreshes + assertThat(cache.getAuthorizations("test_role2").size()).isOne(); + assertThat(cache.getAll().size()).isOne(); + } + + @Test + void testNotFoundUser() throws Exception + { + SystemAuthDatabaseAccessor mockDbAccessor = mock(SystemAuthDatabaseAccessor.class); + when(mockDbAccessor.getAllRolesAndPermissions()) + .thenReturn(Collections.singletonMap("test_role1", Collections.singleton(CassandraActions.SELECT.toAuthorization()))); + SidecarPermissionsDatabaseAccessor mockSidecarPermissionsAccessor = mock(SidecarPermissionsDatabaseAccessor.class); + when(mockSidecarPermissionsAccessor.getAllRolesAndPermissions()) + .thenReturn(Collections.singletonMap("test_role1", Collections.singleton(SidecarActions.CREATE_SNAPSHOT.toAuthorization()))); + SidecarConfiguration mockConfig = mockConfig(); + RoleAuthorizationsCache cache = new RoleAuthorizationsCache(vertx, + executorPools, + mockConfig, + mockDbAccessor, + mockSidecarPermissionsAccessor); + assertThat(cache.getAll().size()).isZero(); + + // wait for cache entries to be refreshed + Thread.sleep(3000); + + // New entries fetched during refreshes + assertThat(cache.getAll().size()).isOne(); + assertThat(cache.getAuthorizations("test_role2")).isNull(); + } + + + @Test + void testBulkload() throws InterruptedException + { + SystemAuthDatabaseAccessor mockDbAccessor = mock(SystemAuthDatabaseAccessor.class); + when(mockDbAccessor.getAllRolesAndPermissions()) + .thenReturn(ImmutableMap.of("test_role1", Collections.singleton(SidecarActions.CREATE_SNAPSHOT.toAuthorization()), + "test_role2", Collections.singleton(SidecarActions.STREAM_SSTABLE.toAuthorization()))); + SidecarPermissionsDatabaseAccessor mockSidecarPermissionsAccessor = mock(SidecarPermissionsDatabaseAccessor.class); + SidecarConfiguration mockConfig = mockConfig(); + RoleAuthorizationsCache cache = new RoleAuthorizationsCache(vertx, + executorPools, + mockConfig, + mockDbAccessor, + mockSidecarPermissionsAccessor); + assertThat(cache.getAll().size()).isZero(); + + // warming cache + vertx.eventBus().publish(ON_SIDECAR_SCHEMA_INITIALIZED.address(), new JsonObject()); + + // wait for cache warming. system_auth.role_permissions table bulk loaded against a single key + Thread.sleep(3000); + assertThat(cache.getAll().size()).isOne(); + assertThat(cache.get(UNIQUE_CACHE_ENTRY).get("test_role1").size()).isOne(); + assertThat(cache.get(UNIQUE_CACHE_ENTRY).get("test_role2").size()).isOne(); + } + + @Test + void testCacheDisabled() + { + SystemAuthDatabaseAccessor mockDbAccessor = mock(SystemAuthDatabaseAccessor.class); + when(mockDbAccessor.getAllRolesAndPermissions()) + .thenReturn(ImmutableMap.of("test_role1", Collections.singleton(SidecarActions.CREATE_SNAPSHOT.toAuthorization()), + "test_role2", Collections.singleton(SidecarActions.STREAM_SSTABLE.toAuthorization()))); + SidecarPermissionsDatabaseAccessor mockSidecarPermissionsAccessor = mock(SidecarPermissionsDatabaseAccessor.class); + SidecarConfiguration mockConfig = mockConfig(); + when(mockConfig.accessControlConfiguration().permissionCacheConfiguration().enabled()).thenReturn(false); + RoleAuthorizationsCache cache = new RoleAuthorizationsCache(vertx, + executorPools, + mockConfig, + mockDbAccessor, + mockSidecarPermissionsAccessor); + assertThat(cache.getAuthorizations("test_role1").size()).isOne(); + assertThat(cache.getAuthorizations("test_role2").size()).isOne(); + } + + @Test + void testEmptyEntriesFromSystemAuthDatabaseAccessor() throws InterruptedException + { + SystemAuthDatabaseAccessor mockDbAccessor = mock(SystemAuthDatabaseAccessor.class); + when(mockDbAccessor.getAllRolesAndPermissions()).thenReturn(Collections.emptyMap()); + SidecarPermissionsDatabaseAccessor mockSidecarPermissionsAccessor = mock(SidecarPermissionsDatabaseAccessor.class); + SidecarConfiguration mockConfig = mockConfig(); + RoleAuthorizationsCache cache = new RoleAuthorizationsCache(vertx, + executorPools, + mockConfig, + mockDbAccessor, + mockSidecarPermissionsAccessor); + assertThat(cache.getAll().size()).isZero(); + + // warming cache + vertx.eventBus().publish(ON_SIDECAR_SCHEMA_INITIALIZED.address(), new JsonObject()); + + // wait for cache warming. system_auth.role_permissions table bulk loaded against a single key + Thread.sleep(3000); + assertThat(cache.getAll().size()).isOne(); + assertThat(cache.get(UNIQUE_CACHE_ENTRY).size()).isZero(); + } + + @Test + void testSidecarPermissionsNotAddedWhenSchemaDisabled() throws InterruptedException + { + SystemAuthDatabaseAccessor mockDbAccessor = mock(SystemAuthDatabaseAccessor.class); + when(mockDbAccessor.getAllRolesAndPermissions()) + .thenReturn(ImmutableMap.of("test_role1", Collections.singleton(CassandraActions.SELECT.toAuthorization()), + "test_role2", Collections.singleton(CassandraActions.CREATE.toAuthorization()))); + SidecarPermissionsDatabaseAccessor mockSidecarPermissionsAccessor = mock(SidecarPermissionsDatabaseAccessor.class); + when(mockDbAccessor.getAllRolesAndPermissions()) + .thenReturn(ImmutableMap.of("test_role3", Collections.singleton(SidecarActions.CREATE_SNAPSHOT.toAuthorization()))); + SidecarConfiguration mockConfig = mockConfig(); + ServiceConfiguration mockServiceConfig = mock(ServiceConfiguration.class); + SchemaKeyspaceConfiguration mockSchemaConfig = mock(SchemaKeyspaceConfiguration.class); + when(mockSchemaConfig.isEnabled()).thenReturn(false); + when(mockServiceConfig.schemaKeyspaceConfiguration()).thenReturn(mockSchemaConfig); + when(mockConfig.serviceConfiguration()).thenReturn(mockServiceConfig); + RoleAuthorizationsCache cache = new RoleAuthorizationsCache(vertx, + executorPools, + mockConfig, + mockDbAccessor, + mockSidecarPermissionsAccessor); + assertThat(cache.getAll().size()).isZero(); + + // warming cache + vertx.eventBus().publish(ON_SIDECAR_SCHEMA_INITIALIZED.address(), new JsonObject()); + + // wait for cache warming. system_auth.role_permissions table bulk loaded against a single key + Thread.sleep(3000); + assertThat(cache.getAll().size()).isOne(); + assertThat(cache.get(UNIQUE_CACHE_ENTRY).get("test_role1").size()).isOne(); + assertThat(cache.get(UNIQUE_CACHE_ENTRY).get("test_role2").size()).isOne(); + assertThat(cache.get(UNIQUE_CACHE_ENTRY).get("test_role3")).isNull(); + } + + private SidecarConfiguration mockConfig() + { + SidecarConfiguration mockConfig = mock(SidecarConfiguration.class); + ServiceConfiguration mockServiceConfig = mock(ServiceConfiguration.class); + SchemaKeyspaceConfiguration mockSchemaConfig = mock(SchemaKeyspaceConfiguration.class); + when(mockSchemaConfig.isEnabled()).thenReturn(true); + when(mockServiceConfig.schemaKeyspaceConfiguration()).thenReturn(mockSchemaConfig); + when(mockConfig.serviceConfiguration()).thenReturn(mockServiceConfig); + AccessControlConfiguration mockAccessControlConfig = mock(AccessControlConfiguration.class); + when(mockConfig.accessControlConfiguration()).thenReturn(mockAccessControlConfig); + CacheConfiguration mockCacheConfig = mock(CacheConfiguration.class); + when(mockCacheConfig.enabled()).thenReturn(true); + when(mockCacheConfig.expireAfterAccessMillis()).thenReturn(3000L); + when(mockCacheConfig.maximumSize()).thenReturn(10L); + when(mockCacheConfig.warmupRetries()).thenReturn(5); + when(mockCacheConfig.warmupRetryIntervalMillis()).thenReturn(1000L); + when(mockAccessControlConfig.permissionCacheConfiguration()).thenReturn(mockCacheConfig); + return mockConfig; + } +} diff --git a/server/src/test/java/org/apache/cassandra/sidecar/acl/authorization/RoleBasedAuthorizationProviderTest.java b/server/src/test/java/org/apache/cassandra/sidecar/acl/authorization/RoleBasedAuthorizationProviderTest.java new file mode 100644 index 000000000..98bc231ea --- /dev/null +++ b/server/src/test/java/org/apache/cassandra/sidecar/acl/authorization/RoleBasedAuthorizationProviderTest.java @@ -0,0 +1,98 @@ +/* + * 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.acl.authorization; + +import java.util.Collections; + +import com.google.common.collect.ImmutableSet; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import io.netty.handler.codec.http.HttpResponseStatus; +import io.vertx.ext.auth.User; +import io.vertx.ext.auth.mtls.impl.MutualTlsUser; +import io.vertx.ext.web.handler.HttpException; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; +import org.apache.cassandra.sidecar.acl.IdentityToRoleCache; + +import static com.datastax.driver.core.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Test for {@link RoleBasedAuthorizationProvider} + */ +@ExtendWith(VertxExtension.class) +public class RoleBasedAuthorizationProviderTest +{ + RoleAuthorizationsCache mockRolePermissionsCache; + + @BeforeEach + void setup() + { + mockRolePermissionsCache = mock(RoleAuthorizationsCache.class); + } + + @Test + void testMissingIdentity() + { + IdentityToRoleCache mockIdentityToRoleCache = mock(IdentityToRoleCache.class); + RoleBasedAuthorizationProvider authorizationProvider = new RoleBasedAuthorizationProvider(mockIdentityToRoleCache, + mockRolePermissionsCache); + User user = User.fromName("test_user"); + assertThatThrownBy(() -> authorizationProvider.getAuthorizations(user)) + .isInstanceOf(HttpException.class) + .hasMessage(HttpResponseStatus.FORBIDDEN.reasonPhrase()); + } + + @Test + void testCassandraRoleNotFound() + { + IdentityToRoleCache mockIdentityToRoleCache = mock(IdentityToRoleCache.class); + RoleBasedAuthorizationProvider authorizationProvider = new RoleBasedAuthorizationProvider(mockIdentityToRoleCache, + mockRolePermissionsCache); + User user = MutualTlsUser.fromIdentities(Collections.singletonList("spiffe://cassandra/sidecar/test_user")); + assertThatThrownBy(() -> authorizationProvider.getAuthorizations(user)) + .isInstanceOf(HttpException.class) + .hasMessage(HttpResponseStatus.FORBIDDEN.reasonPhrase()); + when(mockIdentityToRoleCache.get("spiffe://cassandra/sidecar/test_user")).thenReturn("test_role"); + authorizationProvider.getAuthorizations(user); + } + + @Test + void testAuthorizationsFetched(VertxTestContext testContext) + { + IdentityToRoleCache mockIdentityToRoleCache = mock(IdentityToRoleCache.class); + when(mockIdentityToRoleCache.get("spiffe://cassandra/sidecar/test_user")).thenReturn("test_role"); + RoleAuthorizationsCache mockRolePermissionsCache = mock(RoleAuthorizationsCache.class); + when(mockRolePermissionsCache.getAuthorizations("test_role")) + .thenReturn(ImmutableSet.of(CassandraActions.CREATE.toAuthorization(), SidecarActions.CREATE_SNAPSHOT.toAuthorization())); + RoleBasedAuthorizationProvider authorizationProvider = new RoleBasedAuthorizationProvider(mockIdentityToRoleCache, + mockRolePermissionsCache); + User user = MutualTlsUser.fromIdentities(Collections.singletonList("spiffe://cassandra/sidecar/test_user")); + authorizationProvider.getAuthorizations(user) + .onComplete(v -> { + assertThat(user.authorizations().get(authorizationProvider.getId()).size()).isEqualTo(2); + testContext.completeNow(); + }); + } +} diff --git a/server/src/test/java/org/apache/cassandra/sidecar/acl/authorization/SuperUserCacheTest.java b/server/src/test/java/org/apache/cassandra/sidecar/acl/authorization/SuperUserCacheTest.java new file mode 100644 index 000000000..51733deaf --- /dev/null +++ b/server/src/test/java/org/apache/cassandra/sidecar/acl/authorization/SuperUserCacheTest.java @@ -0,0 +1,122 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.sidecar.acl.authorization; + +import java.util.Collections; + +import com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; +import org.apache.cassandra.sidecar.concurrent.ExecutorPools; +import org.apache.cassandra.sidecar.config.AccessControlConfiguration; +import org.apache.cassandra.sidecar.config.CacheConfiguration; +import org.apache.cassandra.sidecar.config.SidecarConfiguration; +import org.apache.cassandra.sidecar.db.SystemAuthDatabaseAccessor; + +import static org.apache.cassandra.sidecar.ExecutorPoolsHelper.createdSharedTestPool; +import static org.apache.cassandra.sidecar.server.SidecarServerEvents.ON_SIDECAR_SCHEMA_INITIALIZED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Test for {@link SuperUserCache} + */ +public class SuperUserCacheTest +{ + Vertx vertx; + ExecutorPools executorPools; + + @BeforeEach + void setup() + { + vertx = Vertx.vertx(); + executorPools = createdSharedTestPool(vertx); + } + + @Test + void testBulkload() throws InterruptedException + { + SystemAuthDatabaseAccessor mockDbAccessor = mock(SystemAuthDatabaseAccessor.class); + when(mockDbAccessor.getRoles()).thenReturn(ImmutableMap.of("test_role1", true, + "test_role2", false)); + SidecarConfiguration mockConfig = mockConfig(); + SuperUserCache cache = new SuperUserCache(vertx, executorPools, mockConfig, mockDbAccessor); + assertThat(cache.getAll().size()).isZero(); + + // warming cache + vertx.eventBus().publish(ON_SIDECAR_SCHEMA_INITIALIZED.address(), new JsonObject()); + + // wait for cache warming. system_auth.role_permissions table bulk loaded against a single key + Thread.sleep(3000); + assertThat(cache.getAll().size()).isEqualTo(2); + assertThat(cache.isSuperUser("test_role1")).isTrue(); + assertThat(cache.isSuperUser("test_role2")).isFalse(); + } + + @Test + void testCacheDisabled() + { + SystemAuthDatabaseAccessor mockDbAccessor = mock(SystemAuthDatabaseAccessor.class); + when(mockDbAccessor.isSuperUser("test_role")).thenReturn(true); + when(mockDbAccessor.getRoles()).thenReturn(ImmutableMap.of("test_role1", true, + "test_role2", false)); + SidecarConfiguration mockConfig = mockConfig(); + when(mockConfig.accessControlConfiguration().permissionCacheConfiguration().enabled()).thenReturn(false); + SuperUserCache superUserCache = new SuperUserCache(vertx, executorPools, mockConfig, mockDbAccessor); + assertThat(superUserCache.get("test_role")).isTrue(); + assertThat(superUserCache.isSuperUser("test_role")).isTrue(); + assertThat(superUserCache.getAll().size()).isEqualTo(2); + } + + @Test + void testEmptyEntriesFetched() throws InterruptedException + { + SystemAuthDatabaseAccessor mockDbAccessor = mock(SystemAuthDatabaseAccessor.class); + when(mockDbAccessor.getRoles()).thenReturn(Collections.emptyMap()); + SidecarConfiguration mockConfig = mockConfig(); + SuperUserCache cache = new SuperUserCache(vertx, executorPools, mockConfig, mockDbAccessor); + assertThat(cache.getAll().size()).isZero(); + + // warming cache + vertx.eventBus().publish(ON_SIDECAR_SCHEMA_INITIALIZED.address(), new JsonObject()); + + // wait for cache warming. system_auth.role_permissions table bulk loaded against a single key + Thread.sleep(3000); + assertThat(cache.getAll().size()).isZero(); + } + + private SidecarConfiguration mockConfig() + { + SidecarConfiguration mockConfig = mock(SidecarConfiguration.class); + AccessControlConfiguration mockAccessControlConfig = mock(AccessControlConfiguration.class); + when(mockConfig.accessControlConfiguration()).thenReturn(mockAccessControlConfig); + CacheConfiguration mockCacheConfig = mock(CacheConfiguration.class); + when(mockCacheConfig.enabled()).thenReturn(true); + when(mockCacheConfig.expireAfterAccessMillis()).thenReturn(3000L); + when(mockCacheConfig.maximumSize()).thenReturn(10L); + when(mockCacheConfig.warmupRetries()).thenReturn(5); + when(mockCacheConfig.warmupRetryIntervalMillis()).thenReturn(1000L); + when(mockAccessControlConfig.permissionCacheConfiguration()).thenReturn(mockCacheConfig); + return mockConfig; + } +} diff --git a/server/src/test/java/org/apache/cassandra/sidecar/config/SidecarConfigurationTest.java b/server/src/test/java/org/apache/cassandra/sidecar/config/SidecarConfigurationTest.java index f784aaf1f..770e01b37 100644 --- a/server/src/test/java/org/apache/cassandra/sidecar/config/SidecarConfigurationTest.java +++ b/server/src/test/java/org/apache/cassandra/sidecar/config/SidecarConfigurationTest.java @@ -331,6 +331,9 @@ void testAccessControlConfiguration() throws Exception .contains(entry("certificate_validator", "io.vertx.ext.auth.mtls.impl.AllowAllCertificateValidator"), entry("certificate_identity_extractor", "org.apache.cassandra.sidecar.acl.authentication.CassandraIdentityExtractor")); + ParameterizedClassConfiguration authorizer = accessControlConfiguration.authorizerConfiguration(); + assertThat(authorizer.className()).isEqualTo("org.apache.cassandra.sidecar.acl.authorization.RoleBasedAuthorizationProvider"); + assertThat(accessControlConfiguration.adminIdentities().size()).isEqualTo(2); assertThat(accessControlConfiguration.adminIdentities()).contains("spiffe://authorized/admin/identity1"); assertThat(accessControlConfiguration.adminIdentities()).contains("spiffe://authorized/admin/identity2"); diff --git a/server/src/test/java/org/apache/cassandra/sidecar/db/SidecarActionsDatabaseAccessorTest.java b/server/src/test/java/org/apache/cassandra/sidecar/db/SidecarActionsDatabaseAccessorTest.java new file mode 100644 index 000000000..025634d37 --- /dev/null +++ b/server/src/test/java/org/apache/cassandra/sidecar/db/SidecarActionsDatabaseAccessorTest.java @@ -0,0 +1,80 @@ +/* + * 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.db; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import com.datastax.driver.core.BoundStatement; +import com.datastax.driver.core.PreparedStatement; +import com.datastax.driver.core.ResultSet; +import com.datastax.driver.core.Row; +import com.datastax.driver.core.Statement; +import org.apache.cassandra.sidecar.common.server.CQLSessionProvider; +import org.apache.cassandra.sidecar.db.schema.SidecarRolePermissionsSchema; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Test for {@link SidecarPermissionsDatabaseAccessor} + */ +class SidecarActionsDatabaseAccessorTest +{ + @Test + void testReadingInvalidActions() + { + SidecarRolePermissionsSchema mockSchema = mock(SidecarRolePermissionsSchema.class); + PreparedStatement mockStmt = mock(PreparedStatement.class); + BoundStatement mockBoundStmt = mock(BoundStatement.class); + when(mockStmt.bind()).thenReturn(mockBoundStmt); + when(mockSchema.getAllRolesAndPermissions()).thenReturn(mockStmt); + + CQLSessionProvider mockSessionProvider = mock(CQLSessionProvider.class); + TestSidecarPermissionsDatabaseAccessor sidecarPermissionsDatabaseAccessor + = new TestSidecarPermissionsDatabaseAccessor(mockSchema, mockSessionProvider); + + assertThat(sidecarPermissionsDatabaseAccessor.getAllRolesAndPermissions()).isEmpty(); + } + + static class TestSidecarPermissionsDatabaseAccessor extends SidecarPermissionsDatabaseAccessor + { + + protected TestSidecarPermissionsDatabaseAccessor(SidecarRolePermissionsSchema tableSchema, + CQLSessionProvider sessionProvider) + { + super(tableSchema, sessionProvider); + } + + @Override + protected ResultSet execute(Statement statement) + { + ResultSet mockResultSet = mock(ResultSet.class); + Row mockRow = mock(Row.class); + when(mockRow.getString("role")).thenReturn("test_role"); + when(mockRow.getString("resource")).thenReturn("test_resource"); + // invalid wildcard permission set + when(mockRow.getSet("permissions", String.class)).thenReturn(Collections.singleton(":")); + when(mockResultSet.iterator()).thenReturn(Collections.singletonList(mockRow).iterator()); + return mockResultSet; + } + } +} diff --git a/server/src/test/java/org/apache/cassandra/sidecar/db/SystemAuthDatabaseAccessorTest.java b/server/src/test/java/org/apache/cassandra/sidecar/db/SystemAuthDatabaseAccessorTest.java index 7f1efc90f..fe572345f 100644 --- a/server/src/test/java/org/apache/cassandra/sidecar/db/SystemAuthDatabaseAccessorTest.java +++ b/server/src/test/java/org/apache/cassandra/sidecar/db/SystemAuthDatabaseAccessorTest.java @@ -21,8 +21,8 @@ import org.junit.jupiter.api.Test; import org.apache.cassandra.sidecar.common.server.CQLSessionProvider; +import org.apache.cassandra.sidecar.common.server.exceptions.SchemaUnavailableException; import org.apache.cassandra.sidecar.db.schema.SystemAuthSchema; -import org.apache.cassandra.sidecar.exceptions.SchemaUnavailableException; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; diff --git a/server/src/test/java/org/apache/cassandra/sidecar/routes/AccessProtectedRouteBuilderTest.java b/server/src/test/java/org/apache/cassandra/sidecar/routes/AccessProtectedRouteBuilderTest.java new file mode 100644 index 000000000..4721916b7 --- /dev/null +++ b/server/src/test/java/org/apache/cassandra/sidecar/routes/AccessProtectedRouteBuilderTest.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 org.apache.cassandra.sidecar.routes; + +import org.junit.jupiter.api.Test; + +import io.vertx.core.http.HttpMethod; +import io.vertx.ext.auth.authorization.AuthorizationProvider; +import io.vertx.ext.web.Router; +import org.apache.cassandra.sidecar.acl.authorization.AdminIdentityResolver; +import org.apache.cassandra.sidecar.config.AccessControlConfiguration; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +/** + * Test for {@link AccessProtectedRouteBuilder} + */ +public class AccessProtectedRouteBuilderTest +{ + @Test + void testRequiredParameters() + { + AccessControlConfiguration mockConfig = mock(AccessControlConfiguration.class); + AuthorizationProvider mockAuthorizationProvider = mock(AuthorizationProvider.class); + AdminIdentityResolver mockAdminIdentityResolver = mock(AdminIdentityResolver.class); + AccessProtectedRouteBuilder accessProtectedRouteBuilder = new AccessProtectedRouteBuilder(mockConfig, + mockAuthorizationProvider, + mockAdminIdentityResolver); + assertThatThrownBy(accessProtectedRouteBuilder::build).isInstanceOf(IllegalArgumentException.class); + Router mockRouter = mock(Router.class); + accessProtectedRouteBuilder.router(mockRouter); + accessProtectedRouteBuilder.method(HttpMethod.GET); + accessProtectedRouteBuilder.endpoint("/api/v1/__health"); + assertThatThrownBy(accessProtectedRouteBuilder::build).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/server/src/test/java/org/apache/cassandra/sidecar/server/ServerTest.java b/server/src/test/java/org/apache/cassandra/sidecar/server/ServerTest.java index be0a51b95..5e0b4f078 100644 --- a/server/src/test/java/org/apache/cassandra/sidecar/server/ServerTest.java +++ b/server/src/test/java/org/apache/cassandra/sidecar/server/ServerTest.java @@ -308,6 +308,16 @@ void unrecognizedAuthenticationHandlerSet() "UnrecognizedAuthenticationHandler has not been registered"); } + @Test + @DisplayName("Invalid access control config, unrecognized authorization provider set") + void unrecognizedAuthorizationProviderSet() + { + assertThatThrownBy(() -> configureServer("config/sidecar_unrecognized_authorizer.yaml")) + .hasCauseInstanceOf(RuntimeException.class) + .hasMessageContaining("Unrecognized authorization provider org.apache.cassandra.sidecar.acl." + + "authorization.UnrecognizedAuthorizationProvider set"); + } + Future validateHealthEndpoint(String deploymentId) { LOGGER.info("Checking server health 127.0.0.1:{}/api/v1/__health", server.actualPort()); diff --git a/server/src/test/resources/config/sidecar_multiple_instances.yaml b/server/src/test/resources/config/sidecar_multiple_instances.yaml index 705634317..c129ca594 100644 --- a/server/src/test/resources/config/sidecar_multiple_instances.yaml +++ b/server/src/test/resources/config/sidecar_multiple_instances.yaml @@ -139,6 +139,8 @@ access_control: parameters: certificate_validator: io.vertx.ext.auth.mtls.impl.AllowAllCertificateValidator certificate_identity_extractor: org.apache.cassandra.sidecar.acl.authentication.CassandraIdentityExtractor + authorizer: + - class_name: org.apache.cassandra.sidecar.acl.authorization.RoleBasedAuthorizationProvider admin_identities: - spiffe://authorized/admin/identity1 - spiffe://authorized/admin/identity2 diff --git a/server/src/test/resources/config/sidecar_unrecognized_authorizer.yaml b/server/src/test/resources/config/sidecar_unrecognized_authorizer.yaml new file mode 100644 index 000000000..0e10308eb --- /dev/null +++ b/server/src/test/resources/config/sidecar_unrecognized_authorizer.yaml @@ -0,0 +1,122 @@ +# +# Cassandra SideCar configuration file +# +cassandra: + host: localhost + port: 9042 + data_dirs: + - /ccm/test/node1/data0 + - /ccm/test/node1/data1 + staging_dir: /ccm/test/node1/sstable-staging + jmx_host: 127.0.0.1 + jmx_port: 7199 + jmx_role: controlRole + jmx_role_password: controlPassword + jmx_ssl_enabled: true + +sidecar: + host: 0.0.0.0 + port: 9043 + request_idle_timeout_millis: 300000 # this field expects integer value + request_timeout_millis: 300000 + tcp_keep_alive: false + accept_backlog: 1024 + server_verticle_instances: 2 + throttle: + stream_requests_per_sec: 5000 + timeout_sec: 10 + traffic_shaping: + inbound_global_bandwidth_bps: 500 + outbound_global_bandwidth_bps: 1500 + peak_outbound_global_bandwidth_bps: 2000 + max_delay_to_wait_millis: 2500 + check_interval_for_stats_millis: 3000 + sstable_upload: + concurrent_upload_limit: 80 + 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 + sstable_import: + poll_interval_millis: 100 + cache: + expire_after_access_millis: 7200000 # 2 hours + maximum_size: 10000 + sstable_snapshot: + snapshot_list_cache: + expire_after_access_millis: 350 + maximum_size: 450 + worker_pools: + service: + name: "sidecar-worker-pool" + size: 20 + max_execution_time_millis: 60000 # 60 seconds + internal: + name: "sidecar-internal-worker-pool" + size: 20 + max_execution_time_millis: 900000 # 15 minutes + jmx: + max_retries: 42 + retry_delay_millis: 1234 + schema: + is_enabled: false + keyspace: sidecar_internal + replication_strategy: SimpleStrategy + replication_factor: 1 + +# +# 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 + +access_control: + enabled: true + authenticators: + - class_name: org.apache.cassandra.sidecar.acl.authentication.MutualTlsAuthenticationHandlerFactory + parameters: + certificate_validator: io.vertx.ext.auth.mtls.impl.AllowAllCertificateValidator + certificate_identity_extractor: org.apache.cassandra.sidecar.acl.authentication.CassandraIdentityExtractor + authorizer: + - class_name: org.apache.cassandra.sidecar.acl.authorization.UnrecognizedAuthorizationProvider + admin_identities: + - spiffe://authorized/admin/identity1 + - spiffe://authorized/admin/identity2 + permission_cache: + enabled: true + expire_after_access_millis: 300000 + maximum_size: 1000 + warmup_retries: 5 + warmup_retry_interval_millis: 2000 + +healthcheck: + initial_delay_millis: 100 + poll_freq_millis: 30000 + +cassandra_input_validation: + forbidden_keyspaces: + - system_schema + - system_traces + - system_distributed + - system + - system_auth + - system_views + - system_virtual_schema + allowed_chars_for_directory: "[a-zA-Z][a-zA-Z0-9_]{0,47}" + allowed_chars_for_quoted_name: "[a-zA-Z_0-9]{1,48}" + allowed_chars_for_component_name: "[a-zA-Z0-9_-]+(.db|.cql|.json|.crc32|TOC.txt)" + allowed_chars_for_restricted_component_name: "[a-zA-Z0-9_-]+(.db|TOC.txt)"