This is an automated email from the ASF dual-hosted git repository. frankgh pushed a commit to branch trunk in repository https://gitbox.apache.org/repos/asf/cassandra-sidecar.git
The following commit(s) were added to refs/heads/trunk by this push: new 176f85ac CASSSIDECAR-257: Allow getting intended role in Sidecar requests (#223) 176f85ac is described below commit 176f85ac3801fc652099e3c43a0c7594ba5a3476 Author: Saranya Krishnakumar <sarany...@apple.com> AuthorDate: Tue Jun 10 14:13:40 2025 -0700 CASSSIDECAR-257: Allow getting intended role in Sidecar requests (#223) Patch by Saranya Krishnakumar; reviewed by Yifan Cai, Francisco Guerrero for CASSSIDECAR-257 --- CHANGES.txt | 1 + .../common/http/SidecarHttpHeaderNames.java | 11 +++ .../cassandra/sidecar/client/HttpClientConfig.java | 27 ++++++ .../MutualTlsAuthenticationHandler.java | 14 ++- .../ReloadingJwtAuthenticationHandler.java | 13 ++- .../MutualTLSAuthenticationIntegrationTest.java | 107 +++++++++++++++------ .../cassandra/sidecar/client/VertxHttpClient.java | 16 +++ 7 files changed, 155 insertions(+), 34 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index f964ed25..1d13dd06 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,6 @@ 0.2.0 ----- + * Allow getting intended role in Sidecar requests (CASSSIDECAR-257) * Fix configuration for ssl section to be at root level in sidecar.yaml (CASSSIDECAR-256) * Fix restore ranges of failed sidecar-managed restore jobs do not persist (CASSSIDECAR-255) * Added endpoint for listing files for Live Migration (CASSSIDECAR-222) diff --git a/client-common/src/main/java/org/apache/cassandra/sidecar/common/http/SidecarHttpHeaderNames.java b/client-common/src/main/java/org/apache/cassandra/sidecar/common/http/SidecarHttpHeaderNames.java index 347ed59a..d198223a 100644 --- a/client-common/src/main/java/org/apache/cassandra/sidecar/common/http/SidecarHttpHeaderNames.java +++ b/client-common/src/main/java/org/apache/cassandra/sidecar/common/http/SidecarHttpHeaderNames.java @@ -31,4 +31,15 @@ public final class SidecarHttpHeaderNames * {@code "cassandra-content-xxhash32-seed"} */ public static final String CONTENT_XXHASH32_SEED = "cassandra-content-xxhash32-seed"; + /** + * {@code "cassandra-auth-role"} header allows clients to explicitly set intended role for permission evaluation on + * server side. + * <p> + * When {@code "cassandra-auth-role"} header is added, server verifies that the role specified is present among the + * roles assigned to user in identity_to_role table in Cassandra. This mechanism is secure, since sidecar verifies, + * the intended role against the user's actual roles in database. Once verified, server uses only the permissions + * associated with this intended role for authorization checks. If the header is not added, sidecar uses all + * assigned roles for user in database for permission evaluation. + */ + public static final String AUTH_ROLE = "cassandra-auth-role"; } diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/HttpClientConfig.java b/client/src/main/java/org/apache/cassandra/sidecar/client/HttpClientConfig.java index 527ea670..a64ae362 100644 --- a/client/src/main/java/org/apache/cassandra/sidecar/client/HttpClientConfig.java +++ b/client/src/main/java/org/apache/cassandra/sidecar/client/HttpClientConfig.java @@ -20,6 +20,8 @@ package org.apache.cassandra.sidecar.client; import java.io.InputStream; +import org.jetbrains.annotations.Nullable; + /** * Encapsulates {@code HttpClient} configuration parameters. */ @@ -35,6 +37,7 @@ public class HttpClientConfig public static final int DEFAULT_READ_BUFFER_SIZE = 8 * 1024; // 8 KiB public static final String DEFAULT_TRUST_STORE_TYPE = "JKS"; public static final String DEFAULT_KEY_STORE_TYPE = "PKCS12"; + public static final String DEFAULT_CASSANDRA_ROLE = null; private final long timeoutMillis; private final boolean ssl; @@ -50,6 +53,7 @@ public class HttpClientConfig private final InputStream keyStoreInputStream; private final String keyStorePassword; private final String keyStoreType; + private final String cassandraRole; private HttpClientConfig(Builder<?> builder) { @@ -67,6 +71,7 @@ public class HttpClientConfig keyStoreInputStream = builder.keyStoreInputStream; keyStorePassword = builder.keyStorePassword; keyStoreType = builder.keyStoreType; + cassandraRole = builder.cassandraRole; } /** @@ -178,6 +183,15 @@ public class HttpClientConfig return keyStoreType; } + /** + * @return cassandra role + */ + @Nullable + public String cassandraRole() + { + return cassandraRole; + } + /** * {@code HttpClient} builder static inner class. * @@ -199,6 +213,7 @@ public class HttpClientConfig private InputStream keyStoreInputStream; private String keyStorePassword; private String keyStoreType = DEFAULT_KEY_STORE_TYPE; + private String cassandraRole = DEFAULT_CASSANDRA_ROLE; /** * @return a reference to itself @@ -385,6 +400,18 @@ public class HttpClientConfig return self(); } + /** + * Sets the {@code cassandraRole} and returns a reference to this Builder enabling method chaining. + * + * @param cassandraRole the {@code cassandraRole} to set + * @return a reference to this Builder + */ + public T cassandraRole(String cassandraRole) + { + this.cassandraRole = cassandraRole; + return self(); + } + /** * Returns a {@code SidecarClientConfig} built from the parameters previously set. * diff --git a/server/src/main/java/org/apache/cassandra/sidecar/acl/authentication/MutualTlsAuthenticationHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/acl/authentication/MutualTlsAuthenticationHandler.java index 2a25ffe2..8457ea40 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/acl/authentication/MutualTlsAuthenticationHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/acl/authentication/MutualTlsAuthenticationHandler.java @@ -36,6 +36,8 @@ import io.vertx.ext.web.handler.impl.AuthenticationHandlerImpl; import org.apache.cassandra.sidecar.acl.IdentityToRoleCache; import static io.netty.handler.codec.http.HttpResponseStatus.UNAUTHORIZED; +import static org.apache.cassandra.sidecar.common.http.SidecarHttpHeaderNames.AUTH_ROLE; +import static org.apache.cassandra.sidecar.common.utils.StringUtils.isNotEmpty; import static org.apache.cassandra.sidecar.utils.AuthUtils.CASSANDRA_ROLES_ATTRIBUTE_NAME; import static org.apache.cassandra.sidecar.utils.AuthUtils.extractIdentities; import static org.apache.cassandra.sidecar.utils.HttpExceptions.wrapHttpException; @@ -88,10 +90,18 @@ public class MutualTlsAuthenticationHandler extends AuthenticationHandlerImpl<Mu List<String> identities = extractIdentities(authN.result()); List<String> roles = extractCassandraRoles(identities); - if (!roles.isEmpty()) + String roleIntended = ctx.request().getHeader(AUTH_ROLE); + + if (isNotEmpty(roleIntended) && !roles.contains(roleIntended)) { - authN.result().attributes().put(CASSANDRA_ROLES_ATTRIBUTE_NAME, roles); + String errMsg = String.format("None of the identities %s are authorized for role %s", + identities, roleIntended); + handler.handle(Future.failedFuture(wrapHttpException(UNAUTHORIZED, errMsg))); + return; } + + List<String> rolesToAdd = isNotEmpty(roleIntended) ? List.of(roleIntended) : roles; + authN.result().attributes().put(CASSANDRA_ROLES_ATTRIBUTE_NAME, rolesToAdd); handler.handle(authN); }); } diff --git a/server/src/main/java/org/apache/cassandra/sidecar/acl/authentication/ReloadingJwtAuthenticationHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/acl/authentication/ReloadingJwtAuthenticationHandler.java index 8b95bdf7..7172cb24 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/acl/authentication/ReloadingJwtAuthenticationHandler.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/acl/authentication/ReloadingJwtAuthenticationHandler.java @@ -43,6 +43,8 @@ import org.apache.cassandra.sidecar.tasks.PeriodicTaskExecutor; import static io.netty.handler.codec.http.HttpResponseStatus.SERVICE_UNAVAILABLE; import static io.netty.handler.codec.http.HttpResponseStatus.UNAUTHORIZED; +import static org.apache.cassandra.sidecar.common.http.SidecarHttpHeaderNames.AUTH_ROLE; +import static org.apache.cassandra.sidecar.common.utils.StringUtils.isNotEmpty; import static org.apache.cassandra.sidecar.utils.AuthUtils.CASSANDRA_ROLES_ATTRIBUTE_NAME; import static org.apache.cassandra.sidecar.utils.HttpExceptions.wrapHttpException; @@ -105,10 +107,17 @@ extends AuthenticationHandlerImpl<ReloadingJwtAuthenticationHandler.NoOpAuthenti } List<String> roles = extractCassandraRoles(decodedToken); - if (!roles.isEmpty()) + String roleIntended = context.request().getHeader(AUTH_ROLE); + + if (isNotEmpty(roleIntended) && !roles.contains(roleIntended)) { - user.attributes().put(CASSANDRA_ROLES_ATTRIBUTE_NAME, roles); + String errMsg = String.format("User not authorized for role %s", roleIntended); + handler.handle(Future.failedFuture(wrapHttpException(UNAUTHORIZED, errMsg))); + return; } + + List<String> rolesToAdd = isNotEmpty(roleIntended) ? List.of(roleIntended) : roles; + user.attributes().put(CASSANDRA_ROLES_ATTRIBUTE_NAME, rolesToAdd); handler.handle(Future.succeededFuture(user)); }); } diff --git a/server/src/test/integration/org/apache/cassandra/sidecar/acl/MutualTLSAuthenticationIntegrationTest.java b/server/src/test/integration/org/apache/cassandra/sidecar/acl/MutualTLSAuthenticationIntegrationTest.java index eb163dc1..f189071f 100644 --- a/server/src/test/integration/org/apache/cassandra/sidecar/acl/MutualTLSAuthenticationIntegrationTest.java +++ b/server/src/test/integration/org/apache/cassandra/sidecar/acl/MutualTLSAuthenticationIntegrationTest.java @@ -28,6 +28,8 @@ import org.junit.jupiter.api.extension.ExtendWith; import com.datastax.driver.core.Session; import io.netty.handler.codec.http.HttpResponseStatus; +import io.vertx.core.buffer.Buffer; +import io.vertx.ext.web.client.HttpRequest; import io.vertx.ext.web.client.WebClient; import io.vertx.junit5.VertxExtension; import io.vertx.junit5.VertxTestContext; @@ -38,6 +40,7 @@ import org.apache.cassandra.testing.CassandraIntegrationTest; import org.apache.cassandra.testing.CassandraTestContext; import static org.apache.cassandra.sidecar.acl.RoleBasedAuthorizationIntegrationTest.MIN_VERSION_WITH_MTLS; +import static org.apache.cassandra.sidecar.common.http.SidecarHttpHeaderNames.AUTH_ROLE; 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; @@ -68,10 +71,7 @@ class MutualTLSAuthenticationIntegrationTest extends IntegrationTestBase .withFailMessage("mTLS authentication is not supported in 4.0 Cassandra version") .isGreaterThanOrEqualTo(MIN_VERSION_WITH_MTLS); - // required for authentication of sidecar requests to Cassandra. Only superusers can grant permissions - insertIdentityRole(cassandraTestContext, ADMIN_IDENTITY, "cassandra"); - - waitForSchemaReady(1, TimeUnit.MINUTES); + prepareForTest(cassandraTestContext); createRole("cassandra-role", false); insertIdentityRole(cassandraTestContext, "spiffe://cassandra/sidecar/test", "cassandra-role"); @@ -145,10 +145,7 @@ class MutualTLSAuthenticationIntegrationTest extends IntegrationTestBase .withFailMessage("mTLS authentication is not supported in 4.0 Cassandra version") .isGreaterThanOrEqualTo(MIN_VERSION_WITH_MTLS); - // required for authentication of sidecar requests to Cassandra. Only superusers can grant permissions - insertIdentityRole(cassandraContext, ADMIN_IDENTITY, "cassandra"); - - waitForSchemaReady(30, TimeUnit.SECONDS); + prepareForTest(cassandraContext); createRole("superuser", true); createRole("nonsuperuser", false); @@ -180,31 +177,81 @@ class MutualTLSAuthenticationIntegrationTest extends IntegrationTestBase context.completeNow(); } + @CassandraIntegrationTest(authMode = AuthMode.MUTUAL_TLS) + void testRoleIntended(VertxTestContext context, CassandraTestContext cassandraContext) throws Exception + { + assumeThat(cassandraContext.version.major) + .withFailMessage("mTLS authentication is not supported in 4.0 Cassandra version") + .isGreaterThanOrEqualTo(MIN_VERSION_WITH_MTLS); + + prepareForTest(cassandraContext); + + createRole("test-role", true); + createRole("admin", true); + insertIdentityRole(cassandraContext, "spiffe://cassandra/sidecar/testuser", "test-role"); + + // wait for cache refreshes + Thread.sleep(3000); + + Path clientKeystorePath = clientKeystorePath("spiffe://cassandra/sidecar/testuser"); + WebClient client = createClient(clientKeystorePath, truststorePath); + + CountDownLatch authorized = new CountDownLatch(1); + // access with correct role goes through + verifyAccess(client, "test-role", false, context, authorized); + assertThat(authorized.await(30, TimeUnit.SECONDS)).isTrue(); + + // test-role can not assume role admin and act maliciously + client.get(server.actualPort(), "127.0.0.1", "/api/v1/schema/keyspaces") + .putHeader(AUTH_ROLE, "admin") + .send(context.succeeding(response -> { + assertThat(response.statusCode()).isEqualTo(HttpResponseStatus.UNAUTHORIZED.code()); + context.completeNow(); + })); + } + + private void prepareForTest(CassandraTestContext cassandraContext) + { + // required for authentication of sidecar requests to Cassandra. Only superusers can grant permissions + insertIdentityRole(cassandraContext, ADMIN_IDENTITY, "cassandra"); + + waitForSchemaReady(30, TimeUnit.SECONDS); + } + private void verifyAccess(WebClient client, boolean expectForbidden, VertxTestContext context, CountDownLatch latch) + { + verifyAccess(client, null, expectForbidden, context, latch); + } + + private void verifyAccess(WebClient client, String role, boolean expectForbidden, VertxTestContext context, CountDownLatch latch) { String testRoute = "/api/v1/schema/keyspaces"; - client.get(server.actualPort(), "127.0.0.1", testRoute) - .send(response -> { - if (response.cause() != null) - { - context.failNow(response.cause()); - return; - } - - if (expectForbidden) - { - assertThat(response.result().statusCode()).isEqualTo(HttpResponseStatus.FORBIDDEN.code()); - } - else - { - assertThat(response.result().statusCode()).isEqualTo(HttpResponseStatus.OK.code()); - SchemaResponse schemaResponse = response.result().bodyAsJson(SchemaResponse.class); - assertThat(schemaResponse).isNotNull(); - assertThat(schemaResponse.keyspace()).isNull(); - assertThat(schemaResponse.schema()).isNotNull(); - } - latch.countDown(); - }); + HttpRequest<Buffer> request = client.get(server.actualPort(), "127.0.0.1", testRoute); + if (role != null) + { + request = request.putHeader(AUTH_ROLE, role); + } + request.send(response -> { + if (response.cause() != null) + { + context.failNow(response.cause()); + return; + } + + if (expectForbidden) + { + assertThat(response.result().statusCode()).isEqualTo(HttpResponseStatus.FORBIDDEN.code()); + } + else + { + assertThat(response.result().statusCode()).isEqualTo(HttpResponseStatus.OK.code()); + SchemaResponse schemaResponse = response.result().bodyAsJson(SchemaResponse.class); + assertThat(schemaResponse).isNotNull(); + assertThat(schemaResponse.keyspace()).isNull(); + assertThat(schemaResponse.schema()).isNotNull(); + } + latch.countDown(); + }); } private void insertIdentityRole(CassandraTestContext cassandraContext, String identity, String role) diff --git a/vertx-client/src/main/java/org/apache/cassandra/sidecar/client/VertxHttpClient.java b/vertx-client/src/main/java/org/apache/cassandra/sidecar/client/VertxHttpClient.java index a0c2f35c..2d99f503 100644 --- a/vertx-client/src/main/java/org/apache/cassandra/sidecar/client/VertxHttpClient.java +++ b/vertx-client/src/main/java/org/apache/cassandra/sidecar/client/VertxHttpClient.java @@ -57,6 +57,9 @@ import io.vertx.ext.web.codec.BodyCodec; import org.apache.cassandra.sidecar.common.request.Request; import org.apache.cassandra.sidecar.common.request.UploadableRequest; +import static org.apache.cassandra.sidecar.common.http.SidecarHttpHeaderNames.AUTH_ROLE; +import static org.apache.cassandra.sidecar.common.utils.StringUtils.isNullOrEmpty; + /** * An {@link HttpClient} implementation that uses vertx's WebClient internally */ @@ -262,6 +265,8 @@ public class VertxHttpClient implements HttpClient protected HttpRequest<Buffer> applyHeaders(HttpRequest<Buffer> vertxRequest, Map<String, String> headers) { + vertxRequest = applyAuthHeader(vertxRequest); + if (headers == null || headers.isEmpty()) return vertxRequest; @@ -272,6 +277,17 @@ public class VertxHttpClient implements HttpClient return vertxRequest; } + protected HttpRequest<Buffer> applyAuthHeader(HttpRequest<Buffer> vertxRequest) + { + String cassandraRole = config().cassandraRole(); + if (isNullOrEmpty(cassandraRole)) + { + return vertxRequest; + } + + return vertxRequest.putHeader(AUTH_ROLE, cassandraRole); + } + protected Map<String, List<String>> mapHeaders(MultiMap headers) { if (headers == null) --------------------------------------------------------------------- To unsubscribe, e-mail: commits-unsubscr...@cassandra.apache.org For additional commands, e-mail: commits-h...@cassandra.apache.org