Copilot commented on code in PR #5107:
URL: https://github.com/apache/polaris/pull/5107#discussion_r3614371572


##########
tools/testcontainers/keycloak/src/main/java/org/apache/polaris/test/keycloak/KeycloakTestResource.java:
##########
@@ -0,0 +1,101 @@
+/*
+ * 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.polaris.test.keycloak;
+
+import com.google.common.base.Splitter;
+import io.quarkus.test.common.QuarkusTestResourceLifecycleManager;
+import java.util.Map;
+
+public class KeycloakTestResource implements 
QuarkusTestResourceLifecycleManager {
+
+  /**
+   * Initialization argument key for specifying roles to be created in the 
Keycloak server.
+   *
+   * <p>The value associated with this key should be a comma-separated list of 
role names.
+   */
+  public static final String ROLES_INIT_ARG = "roles";
+
+  /**
+   * Initialization argument key for specifying users to be created in the 
Keycloak server.
+   *
+   * <p>The value associated with this key should be a comma-separated list of 
user entries, where
+   * each entry is represented as a key-value pair in the format 
"username=password".
+   */
+  public static final String USERS_INIT_ARG = "users";
+
+  /**
+   * Initialization argument key for specifying role grants to users in the 
Keycloak server.
+   *
+   * <p>The value associated with this key should be a comma-separated list of 
grant entries, where
+   * each entry is represented as a key-value pair in the format 
"username=role".
+   */
+  public static final String GRANTS_INIT_ARG = "grants";
+
+  /**
+   * Initialization argument key for specifying service accounts to be created 
in the Keycloak
+   * server.
+   *
+   * <p>The value associated with this key should be a comma-separated list of 
client entries, where
+   * each entry is represented as a key-value pair in the format 
"client_id=client_secret".
+   */
+  public static final String CLIENTS_INIT_ARG = "clients";
+
+  private Map<String, String> initArgs = Map.of();
+  private KeycloakContainer keycloak;
+
+  @Override
+  public void init(Map<String, String> initArgs) {
+    this.initArgs = Map.copyOf(initArgs);
+  }
+
+  @Override
+  public void inject(TestInjector testInjector) {
+    testInjector.injectIntoFields(
+        keycloak, new TestInjector.AnnotatedAndMatchesType(Keycloak.class, 
KeycloakAccess.class));
+  }
+
+  @Override
+  public Map<String, String> start() {
+    keycloak = new KeycloakContainer();
+    keycloak.start();
+
+    var roles = Splitter.on(",").split(initArgs.get(ROLES_INIT_ARG));
+    var users = 
Splitter.on(",").withKeyValueSeparator("=").split(initArgs.get(USERS_INIT_ARG));
+    var grants = 
Splitter.on(",").withKeyValueSeparator("=").split(initArgs.get(GRANTS_INIT_ARG));
+    var clients = 
Splitter.on(",").withKeyValueSeparator("=").split(initArgs.get(CLIENTS_INIT_ARG));
+

Review Comment:
   Splitter usage will throw when an init arg is missing (null) and will also 
treat an empty string as a single empty entry (e.g., clients="" becomes one 
entry without '=' and fails). This makes the Keycloak test resource fragile 
when callers omit args or have no clients/roles to create.



##########
runtime/service/src/intTest/java/org/apache/polaris/service/it/RestCatalogKeycloakFileIT.java:
##########
@@ -19,28 +19,62 @@
 package org.apache.polaris.service.it;
 
 import io.quarkus.test.junit.QuarkusIntegrationTest;
+import io.quarkus.test.junit.QuarkusTestProfile;
 import io.quarkus.test.junit.TestProfile;
+import java.util.List;
 import java.util.Map;
+import java.util.Optional;
+import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet;
 import org.apache.polaris.service.it.env.ClientPrincipal;
 import org.apache.polaris.service.it.env.ManagementApi;
 import org.apache.polaris.service.it.env.PolarisClient;
 import 
org.apache.polaris.service.it.test.PolarisRestCatalogFileIntegrationTest;
-import org.apache.polaris.test.commons.keycloak.KeycloakAccess;
-import org.apache.polaris.test.commons.keycloak.KeycloakProfile;
+import org.apache.polaris.test.keycloak.Keycloak;
+import org.apache.polaris.test.keycloak.KeycloakAccess;
+import org.apache.polaris.test.keycloak.KeycloakTestResource;
 
 @QuarkusIntegrationTest
-@TestProfile(KeycloakProfile.class)
+@TestProfile(RestCatalogKeycloakFileIT.Profile.class)
 public class RestCatalogKeycloakFileIT extends 
PolarisRestCatalogFileIntegrationTest {
 
-  KeycloakAccess keycloak;
+  public static class Profile implements QuarkusTestProfile {
+
+    @Override
+    public Map<String, String> getConfigOverrides() {
+      return Map.of(
+          "quarkus.oidc.tenant-enabled", "true",
+          "quarkus.oidc.client-id", "polaris",
+          "polaris.authentication.type", "external",
+          "polaris.oidc.principal-mapper.name-claim-path", 
KeycloakAccess.PRINCIPAL_NAME_CLAIM,
+          "polaris.oidc.principal-roles-mapper.filter", "PRINCIPAL_ROLE:.*");
+    }
+
+    @Override
+    public List<TestResourceEntry> testResources() {
+      Optional<String> clients =
+          RootCredentialsSet.fromEnvironment().credentials().values().stream()
+              .map(creds -> creds.clientId() + "=" + creds.clientSecret())
+              .reduce((a, b) -> a + "," + b);
+      return List.of(
+          new TestResourceEntry(
+              KeycloakTestResource.class,
+              Map.of(
+                  KeycloakTestResource.ROLES_INIT_ARG, "PRINCIPAL_ROLE:ALL",
+                  KeycloakTestResource.USERS_INIT_ARG, "root=s3cr3t",
+                  KeycloakTestResource.GRANTS_INIT_ARG, 
"root=PRINCIPAL_ROLE:ALL",
+                  KeycloakTestResource.CLIENTS_INIT_ARG, clients.orElse(""))));
+    }
+  }
+
+  @Keycloak KeycloakAccess keycloak;
 
   @Override
   protected ClientPrincipal createTestPrincipal(
       PolarisClient client, String principalName, String principalRole) {
     ClientPrincipal principal = super.createTestPrincipal(client, 
principalName, principalRole);
-    keycloak.createRole(principalRole);
-    keycloak.createUser(principalName);
-    keycloak.assignRoleToUser(principalRole, principalName);
+    keycloak.createRole("PRINCIPAL_ROLE:" + principalRole);
+    keycloak.createUser(principalName, "s3cr3t");
+    keycloak.assignRoleToUser("PRINCIPAL_ROLE:" + principalRole, 
principalName);

Review Comment:
   Role names are being passed with the "PRINCIPAL_ROLE:" prefix even though 
KeycloakAccess documents callers should pass unprefixed role names. This 
couples test code to a particular implementation detail and can lead to 
inconsistent role naming.



##########
runtime/service/src/intTest/java/org/apache/polaris/service/it/RestCatalogKeycloakFileIT.java:
##########
@@ -19,28 +19,62 @@
 package org.apache.polaris.service.it;
 
 import io.quarkus.test.junit.QuarkusIntegrationTest;
+import io.quarkus.test.junit.QuarkusTestProfile;
 import io.quarkus.test.junit.TestProfile;
+import java.util.List;
 import java.util.Map;
+import java.util.Optional;
+import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet;
 import org.apache.polaris.service.it.env.ClientPrincipal;
 import org.apache.polaris.service.it.env.ManagementApi;
 import org.apache.polaris.service.it.env.PolarisClient;
 import 
org.apache.polaris.service.it.test.PolarisRestCatalogFileIntegrationTest;
-import org.apache.polaris.test.commons.keycloak.KeycloakAccess;
-import org.apache.polaris.test.commons.keycloak.KeycloakProfile;
+import org.apache.polaris.test.keycloak.Keycloak;
+import org.apache.polaris.test.keycloak.KeycloakAccess;
+import org.apache.polaris.test.keycloak.KeycloakTestResource;
 
 @QuarkusIntegrationTest
-@TestProfile(KeycloakProfile.class)
+@TestProfile(RestCatalogKeycloakFileIT.Profile.class)
 public class RestCatalogKeycloakFileIT extends 
PolarisRestCatalogFileIntegrationTest {
 
-  KeycloakAccess keycloak;
+  public static class Profile implements QuarkusTestProfile {
+
+    @Override
+    public Map<String, String> getConfigOverrides() {
+      return Map.of(
+          "quarkus.oidc.tenant-enabled", "true",
+          "quarkus.oidc.client-id", "polaris",
+          "polaris.authentication.type", "external",
+          "polaris.oidc.principal-mapper.name-claim-path", 
KeycloakAccess.PRINCIPAL_NAME_CLAIM,
+          "polaris.oidc.principal-roles-mapper.filter", "PRINCIPAL_ROLE:.*");
+    }
+
+    @Override
+    public List<TestResourceEntry> testResources() {
+      Optional<String> clients =
+          RootCredentialsSet.fromEnvironment().credentials().values().stream()
+              .map(creds -> creds.clientId() + "=" + creds.clientSecret())
+              .reduce((a, b) -> a + "," + b);
+      return List.of(
+          new TestResourceEntry(
+              KeycloakTestResource.class,
+              Map.of(
+                  KeycloakTestResource.ROLES_INIT_ARG, "PRINCIPAL_ROLE:ALL",
+                  KeycloakTestResource.USERS_INIT_ARG, "root=s3cr3t",
+                  KeycloakTestResource.GRANTS_INIT_ARG, 
"root=PRINCIPAL_ROLE:ALL",
+                  KeycloakTestResource.CLIENTS_INIT_ARG, clients.orElse(""))));

Review Comment:
   KeycloakAccess explicitly documents that role names passed to 
createRole/assignRoleToUser/deleteRole should NOT include the "PRINCIPAL_ROLE:" 
prefix. Passing prefixed role names here makes callers inconsistent and risks 
double-prefixing if the KeycloakAccess implementation applies the prefix 
internally (as the previous implementation did).



##########
tools/testcontainers/keycloak/src/main/java/org/apache/polaris/test/keycloak/KeycloakContainer.java:
##########
@@ -0,0 +1,308 @@
+/*
+ * 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.polaris.test.keycloak;
+
+import com.google.common.base.Preconditions;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import org.apache.polaris.containerspec.ContainerSpecHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.output.Slf4jLogConsumer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.json.JsonMapper;
+
+public class KeycloakContainer extends GenericContainer<KeycloakContainer>
+    implements KeycloakAccess {
+
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(KeycloakContainer.class);
+
+  private static final int KEYCLOAK_PORT = 8080;
+  private static final String REALM = "master";
+  private static final String ADMIN_USERNAME = "admin";
+  private static final String ADMIN_PASSWORD = "admin";
+
+  private URI issuerUrl;
+  private URI tokenEndpoint;
+  private HttpClient httpClient;
+
+  @SuppressWarnings("resource")
+  public KeycloakContainer() {
+    super(
+        ContainerSpecHelper.containerSpecHelper("keycloak", 
KeycloakContainer.class)
+            .dockerImageName(null)
+            .asCanonicalNameString());
+    withExposedPorts(KEYCLOAK_PORT);
+    withEnv("KEYCLOAK_ADMIN", ADMIN_USERNAME);
+    withEnv("KEYCLOAK_ADMIN_PASSWORD", ADMIN_PASSWORD);
+    withEnv("KC_LOG_LEVEL", getRootLoggerLevel() + ",org.keycloak:" + 
getKeycloakLoggerLevel());
+    withCommand("start-dev");
+    waitingFor(
+        Wait.forHttp("/realms/" + REALM)
+            .forStatusCode(200)
+            .withStartupTimeout(Duration.ofMinutes(2)));
+    withLogConsumer(new Slf4jLogConsumer(LOGGER));
+  }
+
+  @Override
+  public void start() {
+    super.start();
+    httpClient = HttpClient.newHttpClient();
+    String baseUrl = "http://"; + getHost() + ":" + 
getMappedPort(KEYCLOAK_PORT);
+    issuerUrl = URI.create(baseUrl + "/realms/" + REALM + "/");
+    tokenEndpoint = issuerUrl.resolve("protocol/openid-connect/token");
+  }
+
+  @Override
+  public void stop() {
+    super.stop();
+    httpClient = null;
+  }
+
+  @Override
+  public URI getIssuerUrl() {
+    return issuerUrl;
+  }
+
+  @Override
+  public URI getTokenEndpoint() {
+    return tokenEndpoint;
+  }
+
+  /*
+   * The methods below were taken from org.keycloak:keycloak-admin-client. We 
don't depend on that
+   * artifact directly to avoid bringing in RESTEasy Classic transitively.
+   */
+
+  @Override
+  public void createRole(String roleName) {
+    String token = getAdminToken();
+    int status = adminPost(realmAdminUrl("/roles"), "{\"name\":\"" + roleName 
+ "\"}", token);
+    Preconditions.checkState(
+        status == 201, "Failed to create role '%s', status: %s", roleName, 
status);
+  }

Review Comment:
   KeycloakAccess specifies that role names passed to createRole should not 
include the "PRINCIPAL_ROLE:" prefix, but this implementation currently uses 
the provided name verbatim. This makes callers responsible for prefixing and 
conflicts with the interface contract (and previous behavior).



##########
runtime/service/src/intTest/java/org/apache/polaris/service/it/RestCatalogKeycloakFileIT.java:
##########
@@ -72,7 +106,7 @@ protected void cleanUp(PolarisClient client, String 
adminToken) {
             });
     managementApi.listPrincipalRoles().stream()
         .filter(r -> client.ownedName(r.getName()))
-        .forEach(role -> keycloak.deleteRole(role.getName()));
+        .forEach(role -> keycloak.deleteRole("PRINCIPAL_ROLE:" + 
role.getName()));

Review Comment:
   KeycloakAccess documents deleteRole should receive an unprefixed role name, 
but this call passes a prefixed value. Keeping callers unprefixed avoids 
double-prefixing and keeps behavior consistent across implementations.



##########
tools/testcontainers/keycloak/src/main/java/org/apache/polaris/test/keycloak/KeycloakContainer.java:
##########
@@ -0,0 +1,308 @@
+/*
+ * 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.polaris.test.keycloak;
+
+import com.google.common.base.Preconditions;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import org.apache.polaris.containerspec.ContainerSpecHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.output.Slf4jLogConsumer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.json.JsonMapper;
+
+public class KeycloakContainer extends GenericContainer<KeycloakContainer>
+    implements KeycloakAccess {
+
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(KeycloakContainer.class);
+
+  private static final int KEYCLOAK_PORT = 8080;
+  private static final String REALM = "master";
+  private static final String ADMIN_USERNAME = "admin";
+  private static final String ADMIN_PASSWORD = "admin";
+
+  private URI issuerUrl;
+  private URI tokenEndpoint;
+  private HttpClient httpClient;
+
+  @SuppressWarnings("resource")
+  public KeycloakContainer() {
+    super(
+        ContainerSpecHelper.containerSpecHelper("keycloak", 
KeycloakContainer.class)
+            .dockerImageName(null)
+            .asCanonicalNameString());
+    withExposedPorts(KEYCLOAK_PORT);
+    withEnv("KEYCLOAK_ADMIN", ADMIN_USERNAME);
+    withEnv("KEYCLOAK_ADMIN_PASSWORD", ADMIN_PASSWORD);
+    withEnv("KC_LOG_LEVEL", getRootLoggerLevel() + ",org.keycloak:" + 
getKeycloakLoggerLevel());
+    withCommand("start-dev");
+    waitingFor(
+        Wait.forHttp("/realms/" + REALM)
+            .forStatusCode(200)
+            .withStartupTimeout(Duration.ofMinutes(2)));
+    withLogConsumer(new Slf4jLogConsumer(LOGGER));
+  }
+
+  @Override
+  public void start() {
+    super.start();
+    httpClient = HttpClient.newHttpClient();
+    String baseUrl = "http://"; + getHost() + ":" + 
getMappedPort(KEYCLOAK_PORT);
+    issuerUrl = URI.create(baseUrl + "/realms/" + REALM + "/");
+    tokenEndpoint = issuerUrl.resolve("protocol/openid-connect/token");
+  }
+
+  @Override
+  public void stop() {
+    super.stop();
+    httpClient = null;
+  }
+
+  @Override
+  public URI getIssuerUrl() {
+    return issuerUrl;
+  }
+
+  @Override
+  public URI getTokenEndpoint() {
+    return tokenEndpoint;
+  }
+
+  /*
+   * The methods below were taken from org.keycloak:keycloak-admin-client. We 
don't depend on that
+   * artifact directly to avoid bringing in RESTEasy Classic transitively.
+   */
+
+  @Override
+  public void createRole(String roleName) {
+    String token = getAdminToken();
+    int status = adminPost(realmAdminUrl("/roles"), "{\"name\":\"" + roleName 
+ "\"}", token);
+    Preconditions.checkState(
+        status == 201, "Failed to create role '%s', status: %s", roleName, 
status);
+  }
+
+  @Override
+  public void createUser(String name, String password) {
+    String token = getAdminToken();
+    String body =
+        """
+        {
+          "enabled": true,
+          "username": "%s",
+          "firstName": "%s",
+          "lastName": "%s",
+          "email": "%[email protected]",
+          "emailVerified": true,
+          "requiredActions": [],
+          "credentials": [
+            {
+              "type": "password",
+              "value": "%s",
+              "temporary": false
+            }
+          ]
+        }"""
+            .formatted(name, name, name, name, password);
+    int status = adminPost(realmAdminUrl("/users"), body, token);
+    Preconditions.checkState(status == 201, "Failed to create user '%s', 
status: %s", name, status);
+  }
+
+  @Override
+  public void assignRoleToUser(String role, String user) {
+    String token = getAdminToken();
+    String userId = findUserId(user, token);
+    JsonNode roleRep = adminGet(realmAdminUrl("/roles/" + encode(role)), 
token);
+    String body = "[" + JsonMapper.shared().writeValueAsString(roleRep) + "]";
+    int status = adminPost(realmAdminUrl("/users/" + userId + 
"/role-mappings/realm"), body, token);
+    Preconditions.checkState(
+        status == 204, "Failed to assign role '%s' to user '%s', status: %s", 
role, user, status);
+  }
+
+  @Override
+  public void createServiceAccount(String clientId, String clientSecret) {
+    String token = getAdminToken();
+    String body =
+        """
+        {
+          "clientId": "%s",
+          "secret": "%s",
+          "publicClient": false,
+          "serviceAccountsEnabled": true,
+          "directAccessGrantsEnabled": true
+        }"""
+            .formatted(clientId, clientSecret);
+    int status = adminPost(realmAdminUrl("/clients"), body, token);
+    Preconditions.checkState(
+        status == 201, "Failed to create client '%s', status: %s", clientId, 
status);
+  }
+
+  @Override
+  public void deleteRole(String name) {
+    String token = getAdminToken();
+    int status = adminDelete(realmAdminUrl("/roles/" + encode(name)), token);
+    Preconditions.checkState(status == 204, "Failed to delete role '%s', 
status: %s", name, status);
+  }
+
+  @Override
+  public void deleteUser(String name) {
+    String token = getAdminToken();
+    JsonNode users = adminGet(realmAdminUrl("/users?username=" + 
encode(name)), token);
+    for (JsonNode user : users) {
+      if (name.equals(user.get("username").asString())) {
+        adminDelete(realmAdminUrl("/users/" + user.get("id").asString()), 
token);
+      }
+    }
+  }
+
+  @Override
+  public void deleteServiceAccount(String clientId) {
+    String token = getAdminToken();
+    JsonNode clients = adminGet(realmAdminUrl("/clients?clientId=" + 
encode(clientId)), token);
+    for (JsonNode client : clients) {
+      adminDelete(realmAdminUrl("/clients/" + client.get("id").asString()), 
token);
+    }
+  }
+
+  private String getAdminToken() {
+    String formBody =
+        "grant_type=password"
+            + "&client_id=admin-cli"
+            + "&username="
+            + ADMIN_USERNAME
+            + "&password="
+            + ADMIN_PASSWORD;
+    try {
+      HttpRequest request =
+          HttpRequest.newBuilder()
+              .uri(tokenEndpoint)
+              .header("Content-Type", "application/x-www-form-urlencoded")
+              .POST(HttpRequest.BodyPublishers.ofString(formBody))
+              .build();
+      HttpResponse<InputStream> response =
+          httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
+      return 
JsonMapper.shared().readTree(response.body()).get("access_token").asString();

Review Comment:
   getAdminToken() parses the response body without validating the HTTP status 
or presence of access_token. If Keycloak returns a non-200 (startup timing, 
auth error, etc.), this will fail with a confusing Jackson/NullPointer error 
instead of a clear message.



##########
tools/testcontainers/keycloak/src/main/java/org/apache/polaris/test/keycloak/KeycloakContainer.java:
##########
@@ -0,0 +1,308 @@
+/*
+ * 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.polaris.test.keycloak;
+
+import com.google.common.base.Preconditions;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import org.apache.polaris.containerspec.ContainerSpecHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.output.Slf4jLogConsumer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.json.JsonMapper;
+
+public class KeycloakContainer extends GenericContainer<KeycloakContainer>
+    implements KeycloakAccess {
+
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(KeycloakContainer.class);
+
+  private static final int KEYCLOAK_PORT = 8080;
+  private static final String REALM = "master";
+  private static final String ADMIN_USERNAME = "admin";
+  private static final String ADMIN_PASSWORD = "admin";
+
+  private URI issuerUrl;
+  private URI tokenEndpoint;
+  private HttpClient httpClient;
+
+  @SuppressWarnings("resource")
+  public KeycloakContainer() {
+    super(
+        ContainerSpecHelper.containerSpecHelper("keycloak", 
KeycloakContainer.class)
+            .dockerImageName(null)
+            .asCanonicalNameString());
+    withExposedPorts(KEYCLOAK_PORT);
+    withEnv("KEYCLOAK_ADMIN", ADMIN_USERNAME);
+    withEnv("KEYCLOAK_ADMIN_PASSWORD", ADMIN_PASSWORD);
+    withEnv("KC_LOG_LEVEL", getRootLoggerLevel() + ",org.keycloak:" + 
getKeycloakLoggerLevel());
+    withCommand("start-dev");
+    waitingFor(
+        Wait.forHttp("/realms/" + REALM)
+            .forStatusCode(200)
+            .withStartupTimeout(Duration.ofMinutes(2)));
+    withLogConsumer(new Slf4jLogConsumer(LOGGER));
+  }
+
+  @Override
+  public void start() {
+    super.start();
+    httpClient = HttpClient.newHttpClient();
+    String baseUrl = "http://"; + getHost() + ":" + 
getMappedPort(KEYCLOAK_PORT);
+    issuerUrl = URI.create(baseUrl + "/realms/" + REALM + "/");
+    tokenEndpoint = issuerUrl.resolve("protocol/openid-connect/token");
+  }
+
+  @Override
+  public void stop() {
+    super.stop();
+    httpClient = null;
+  }
+
+  @Override
+  public URI getIssuerUrl() {
+    return issuerUrl;
+  }
+
+  @Override
+  public URI getTokenEndpoint() {
+    return tokenEndpoint;
+  }
+
+  /*
+   * The methods below were taken from org.keycloak:keycloak-admin-client. We 
don't depend on that
+   * artifact directly to avoid bringing in RESTEasy Classic transitively.
+   */
+
+  @Override
+  public void createRole(String roleName) {
+    String token = getAdminToken();
+    int status = adminPost(realmAdminUrl("/roles"), "{\"name\":\"" + roleName 
+ "\"}", token);
+    Preconditions.checkState(
+        status == 201, "Failed to create role '%s', status: %s", roleName, 
status);
+  }
+
+  @Override
+  public void createUser(String name, String password) {
+    String token = getAdminToken();
+    String body =
+        """
+        {
+          "enabled": true,
+          "username": "%s",
+          "firstName": "%s",
+          "lastName": "%s",
+          "email": "%[email protected]",
+          "emailVerified": true,
+          "requiredActions": [],
+          "credentials": [
+            {
+              "type": "password",
+              "value": "%s",
+              "temporary": false
+            }
+          ]
+        }"""
+            .formatted(name, name, name, name, password);
+    int status = adminPost(realmAdminUrl("/users"), body, token);
+    Preconditions.checkState(status == 201, "Failed to create user '%s', 
status: %s", name, status);
+  }
+
+  @Override
+  public void assignRoleToUser(String role, String user) {
+    String token = getAdminToken();
+    String userId = findUserId(user, token);
+    JsonNode roleRep = adminGet(realmAdminUrl("/roles/" + encode(role)), 
token);
+    String body = "[" + JsonMapper.shared().writeValueAsString(roleRep) + "]";
+    int status = adminPost(realmAdminUrl("/users/" + userId + 
"/role-mappings/realm"), body, token);
+    Preconditions.checkState(
+        status == 204, "Failed to assign role '%s' to user '%s', status: %s", 
role, user, status);
+  }
+
+  @Override
+  public void createServiceAccount(String clientId, String clientSecret) {
+    String token = getAdminToken();
+    String body =
+        """
+        {
+          "clientId": "%s",
+          "secret": "%s",
+          "publicClient": false,
+          "serviceAccountsEnabled": true,
+          "directAccessGrantsEnabled": true
+        }"""
+            .formatted(clientId, clientSecret);
+    int status = adminPost(realmAdminUrl("/clients"), body, token);
+    Preconditions.checkState(
+        status == 201, "Failed to create client '%s', status: %s", clientId, 
status);
+  }
+
+  @Override
+  public void deleteRole(String name) {
+    String token = getAdminToken();
+    int status = adminDelete(realmAdminUrl("/roles/" + encode(name)), token);
+    Preconditions.checkState(status == 204, "Failed to delete role '%s', 
status: %s", name, status);
+  }

Review Comment:
   KeycloakAccess documents deleteRole should receive an unprefixed role name, 
but this implementation currently deletes the role using the provided name 
verbatim. To keep callers consistent, apply the "PRINCIPAL_ROLE:" prefix inside 
the container before calling the admin API.



##########
tools/testcontainers/keycloak/src/main/java/org/apache/polaris/test/keycloak/KeycloakContainer.java:
##########
@@ -0,0 +1,308 @@
+/*
+ * 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.polaris.test.keycloak;
+
+import com.google.common.base.Preconditions;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import org.apache.polaris.containerspec.ContainerSpecHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.output.Slf4jLogConsumer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.json.JsonMapper;
+
+public class KeycloakContainer extends GenericContainer<KeycloakContainer>
+    implements KeycloakAccess {
+
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(KeycloakContainer.class);
+
+  private static final int KEYCLOAK_PORT = 8080;
+  private static final String REALM = "master";
+  private static final String ADMIN_USERNAME = "admin";
+  private static final String ADMIN_PASSWORD = "admin";
+
+  private URI issuerUrl;
+  private URI tokenEndpoint;
+  private HttpClient httpClient;
+
+  @SuppressWarnings("resource")
+  public KeycloakContainer() {
+    super(
+        ContainerSpecHelper.containerSpecHelper("keycloak", 
KeycloakContainer.class)
+            .dockerImageName(null)
+            .asCanonicalNameString());
+    withExposedPorts(KEYCLOAK_PORT);
+    withEnv("KEYCLOAK_ADMIN", ADMIN_USERNAME);
+    withEnv("KEYCLOAK_ADMIN_PASSWORD", ADMIN_PASSWORD);
+    withEnv("KC_LOG_LEVEL", getRootLoggerLevel() + ",org.keycloak:" + 
getKeycloakLoggerLevel());
+    withCommand("start-dev");
+    waitingFor(
+        Wait.forHttp("/realms/" + REALM)
+            .forStatusCode(200)
+            .withStartupTimeout(Duration.ofMinutes(2)));
+    withLogConsumer(new Slf4jLogConsumer(LOGGER));
+  }
+
+  @Override
+  public void start() {
+    super.start();
+    httpClient = HttpClient.newHttpClient();
+    String baseUrl = "http://"; + getHost() + ":" + 
getMappedPort(KEYCLOAK_PORT);
+    issuerUrl = URI.create(baseUrl + "/realms/" + REALM + "/");
+    tokenEndpoint = issuerUrl.resolve("protocol/openid-connect/token");
+  }
+
+  @Override
+  public void stop() {
+    super.stop();
+    httpClient = null;
+  }
+
+  @Override
+  public URI getIssuerUrl() {
+    return issuerUrl;
+  }
+
+  @Override
+  public URI getTokenEndpoint() {
+    return tokenEndpoint;
+  }
+
+  /*
+   * The methods below were taken from org.keycloak:keycloak-admin-client. We 
don't depend on that
+   * artifact directly to avoid bringing in RESTEasy Classic transitively.
+   */
+
+  @Override
+  public void createRole(String roleName) {
+    String token = getAdminToken();
+    int status = adminPost(realmAdminUrl("/roles"), "{\"name\":\"" + roleName 
+ "\"}", token);
+    Preconditions.checkState(
+        status == 201, "Failed to create role '%s', status: %s", roleName, 
status);
+  }
+
+  @Override
+  public void createUser(String name, String password) {
+    String token = getAdminToken();
+    String body =
+        """
+        {
+          "enabled": true,
+          "username": "%s",
+          "firstName": "%s",
+          "lastName": "%s",
+          "email": "%[email protected]",
+          "emailVerified": true,
+          "requiredActions": [],
+          "credentials": [
+            {
+              "type": "password",
+              "value": "%s",
+              "temporary": false
+            }
+          ]
+        }"""
+            .formatted(name, name, name, name, password);
+    int status = adminPost(realmAdminUrl("/users"), body, token);
+    Preconditions.checkState(status == 201, "Failed to create user '%s', 
status: %s", name, status);
+  }
+
+  @Override
+  public void assignRoleToUser(String role, String user) {
+    String token = getAdminToken();
+    String userId = findUserId(user, token);
+    JsonNode roleRep = adminGet(realmAdminUrl("/roles/" + encode(role)), 
token);
+    String body = "[" + JsonMapper.shared().writeValueAsString(roleRep) + "]";
+    int status = adminPost(realmAdminUrl("/users/" + userId + 
"/role-mappings/realm"), body, token);
+    Preconditions.checkState(
+        status == 204, "Failed to assign role '%s' to user '%s', status: %s", 
role, user, status);
+  }
+
+  @Override
+  public void createServiceAccount(String clientId, String clientSecret) {
+    String token = getAdminToken();
+    String body =
+        """
+        {
+          "clientId": "%s",
+          "secret": "%s",
+          "publicClient": false,
+          "serviceAccountsEnabled": true,
+          "directAccessGrantsEnabled": true
+        }"""
+            .formatted(clientId, clientSecret);
+    int status = adminPost(realmAdminUrl("/clients"), body, token);
+    Preconditions.checkState(
+        status == 201, "Failed to create client '%s', status: %s", clientId, 
status);
+  }
+
+  @Override
+  public void deleteRole(String name) {
+    String token = getAdminToken();
+    int status = adminDelete(realmAdminUrl("/roles/" + encode(name)), token);
+    Preconditions.checkState(status == 204, "Failed to delete role '%s', 
status: %s", name, status);
+  }
+
+  @Override
+  public void deleteUser(String name) {
+    String token = getAdminToken();
+    JsonNode users = adminGet(realmAdminUrl("/users?username=" + 
encode(name)), token);
+    for (JsonNode user : users) {
+      if (name.equals(user.get("username").asString())) {
+        adminDelete(realmAdminUrl("/users/" + user.get("id").asString()), 
token);
+      }
+    }
+  }
+
+  @Override
+  public void deleteServiceAccount(String clientId) {
+    String token = getAdminToken();
+    JsonNode clients = adminGet(realmAdminUrl("/clients?clientId=" + 
encode(clientId)), token);
+    for (JsonNode client : clients) {
+      adminDelete(realmAdminUrl("/clients/" + client.get("id").asString()), 
token);
+    }
+  }
+
+  private String getAdminToken() {
+    String formBody =
+        "grant_type=password"
+            + "&client_id=admin-cli"
+            + "&username="
+            + ADMIN_USERNAME
+            + "&password="
+            + ADMIN_PASSWORD;
+    try {
+      HttpRequest request =
+          HttpRequest.newBuilder()
+              .uri(tokenEndpoint)
+              .header("Content-Type", "application/x-www-form-urlencoded")
+              .POST(HttpRequest.BodyPublishers.ofString(formBody))
+              .build();
+      HttpResponse<InputStream> response =
+          httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
+      return 
JsonMapper.shared().readTree(response.body()).get("access_token").asString();
+    } catch (IOException e) {
+      throw new UncheckedIOException("Failed to obtain Keycloak admin token", 
e);
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+      throw new RuntimeException("Failed to obtain Keycloak admin token", e);
+    }
+  }
+
+  private JsonNode adminGet(URI uri, String token) {
+    try {
+      HttpRequest request =
+          HttpRequest.newBuilder()
+              .uri(uri)
+              .header("Authorization", "Bearer " + token)
+              .GET()
+              .build();
+      HttpResponse<InputStream> response =
+          httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
+      return JsonMapper.shared().readTree(response.body());

Review Comment:
   adminGet() always attempts to parse the response body as JSON even when the 
server returns a non-200 status. This can mask the real failure (e.g., 401/404) 
behind a JSON parsing exception.



##########
tools/testcontainers/keycloak/src/main/java/org/apache/polaris/test/keycloak/KeycloakContainer.java:
##########
@@ -0,0 +1,308 @@
+/*
+ * 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.polaris.test.keycloak;
+
+import com.google.common.base.Preconditions;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import org.apache.polaris.containerspec.ContainerSpecHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.output.Slf4jLogConsumer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.json.JsonMapper;
+
+public class KeycloakContainer extends GenericContainer<KeycloakContainer>
+    implements KeycloakAccess {
+
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(KeycloakContainer.class);
+
+  private static final int KEYCLOAK_PORT = 8080;
+  private static final String REALM = "master";
+  private static final String ADMIN_USERNAME = "admin";
+  private static final String ADMIN_PASSWORD = "admin";
+
+  private URI issuerUrl;
+  private URI tokenEndpoint;
+  private HttpClient httpClient;
+
+  @SuppressWarnings("resource")
+  public KeycloakContainer() {
+    super(
+        ContainerSpecHelper.containerSpecHelper("keycloak", 
KeycloakContainer.class)
+            .dockerImageName(null)
+            .asCanonicalNameString());
+    withExposedPorts(KEYCLOAK_PORT);
+    withEnv("KEYCLOAK_ADMIN", ADMIN_USERNAME);
+    withEnv("KEYCLOAK_ADMIN_PASSWORD", ADMIN_PASSWORD);
+    withEnv("KC_LOG_LEVEL", getRootLoggerLevel() + ",org.keycloak:" + 
getKeycloakLoggerLevel());
+    withCommand("start-dev");
+    waitingFor(
+        Wait.forHttp("/realms/" + REALM)
+            .forStatusCode(200)
+            .withStartupTimeout(Duration.ofMinutes(2)));
+    withLogConsumer(new Slf4jLogConsumer(LOGGER));
+  }
+
+  @Override
+  public void start() {
+    super.start();
+    httpClient = HttpClient.newHttpClient();
+    String baseUrl = "http://"; + getHost() + ":" + 
getMappedPort(KEYCLOAK_PORT);
+    issuerUrl = URI.create(baseUrl + "/realms/" + REALM + "/");
+    tokenEndpoint = issuerUrl.resolve("protocol/openid-connect/token");
+  }
+
+  @Override
+  public void stop() {
+    super.stop();
+    httpClient = null;
+  }
+
+  @Override
+  public URI getIssuerUrl() {
+    return issuerUrl;
+  }
+
+  @Override
+  public URI getTokenEndpoint() {
+    return tokenEndpoint;
+  }
+
+  /*
+   * The methods below were taken from org.keycloak:keycloak-admin-client. We 
don't depend on that
+   * artifact directly to avoid bringing in RESTEasy Classic transitively.
+   */
+
+  @Override
+  public void createRole(String roleName) {
+    String token = getAdminToken();
+    int status = adminPost(realmAdminUrl("/roles"), "{\"name\":\"" + roleName 
+ "\"}", token);
+    Preconditions.checkState(
+        status == 201, "Failed to create role '%s', status: %s", roleName, 
status);
+  }
+
+  @Override
+  public void createUser(String name, String password) {
+    String token = getAdminToken();
+    String body =
+        """
+        {
+          "enabled": true,
+          "username": "%s",
+          "firstName": "%s",
+          "lastName": "%s",
+          "email": "%[email protected]",
+          "emailVerified": true,
+          "requiredActions": [],
+          "credentials": [
+            {
+              "type": "password",
+              "value": "%s",
+              "temporary": false
+            }
+          ]
+        }"""
+            .formatted(name, name, name, name, password);
+    int status = adminPost(realmAdminUrl("/users"), body, token);
+    Preconditions.checkState(status == 201, "Failed to create user '%s', 
status: %s", name, status);
+  }
+
+  @Override
+  public void assignRoleToUser(String role, String user) {
+    String token = getAdminToken();
+    String userId = findUserId(user, token);
+    JsonNode roleRep = adminGet(realmAdminUrl("/roles/" + encode(role)), 
token);
+    String body = "[" + JsonMapper.shared().writeValueAsString(roleRep) + "]";
+    int status = adminPost(realmAdminUrl("/users/" + userId + 
"/role-mappings/realm"), body, token);
+    Preconditions.checkState(
+        status == 204, "Failed to assign role '%s' to user '%s', status: %s", 
role, user, status);
+  }

Review Comment:
   KeycloakAccess documents assignRoleToUser should take an unprefixed role 
name, but this implementation looks up the role using the provided name 
directly. To keep the interface contract, apply the "PRINCIPAL_ROLE:" prefix 
inside the container when calling the admin API.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


Reply via email to