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

Reply via email to