github-advanced-security[bot] commented on code in PR #589:
URL: https://github.com/apache/airavata/pull/589#discussion_r2855666453


##########
.github/workflows/maven-build.yml:
##########
@@ -4,33 +4,23 @@
   pull_request:
     branches:
       - master
+      - service-layer-improvements
   push:
     branches:
       - master
+      - service-layer-improvements
 
 jobs:
   build:
-    runs-on: ubuntu-22.04
+    runs-on: ubuntu-latest
     steps:
-      - name: Set up OS dependencies
-        run: |
-          sudo apt-get update
-          sudo apt-get install -y build-essential automake bison flex 
libboost-all-dev libevent-dev libssl-dev libtool pkg-config
-      - name: Set up Thrift 0.22.0
-        run: |
-          wget -q https://dlcdn.apache.org/thrift/0.22.0/thrift-0.22.0.tar.gz
-          tar -xzf thrift-0.22.0.tar.gz
-          cd thrift-0.22.0
-          ./configure --without-rs --enable-libs=no --enable-tests=no
-          make -j$(nproc)
-          sudo make install
-          thrift --version
-      - name: Set up JDK 17
+      - name: Checkout code
+        uses: actions/checkout@v4
+      - name: Set up JDK 25
         uses: actions/setup-java@v4
         with:
           distribution: "temurin"
-          java-version: "17"
-      - name: Checkout code
-        uses: actions/checkout@v4
-      - name: Build with Maven (skip tests)
-        run: mvn clean install -DskipTests
+          java-version: "25"
+          cache: "maven"
+      - name: Build and test with Maven
+        run: ./mvnw clean install

Review Comment:
   ## Workflow does not contain permissions
   
   Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. 
Consider setting an explicit permissions block, using the following as a 
minimal starting point: {{contents: read}}
   
   [Show more 
details](https://github.com/apache/airavata/security/code-scanning/64)



##########
dev-tools/ansible/tests/test_base_role.py:
##########
@@ -0,0 +1,104 @@
+#
+#
+# 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.
+#
+
+"""Tests for the base role on all distributions."""
+
+import pytest
+import paramiko
+from pathlib import Path
+
+from tests.fixtures.containers import ubuntu_container, rocky_container, 
centos_container
+
+
+def _test_base_role(container, ansible_runner, inventory_generator, 
test_ssh_key, collection_verifier):
+    """Helper function to test base role on any distribution."""
+    container_info = container.container_info
+    inventory = inventory_generator(
+        container_info=container_info,
+        group_name="base",
+        extra_vars={
+            "user": "airavata",
+            "group": "airavata",
+            "db_password": "test_password",
+            "mysql_root_password": "test_root_password",
+        },
+    )
+    
+    # Create a minimal playbook for base role
+    playbook_content = """---
+- name: Base Role Test
+  hosts: base
+  become: true
+  roles:
+    - base
+"""
+    playbook_path = inventory.parent / "test_base.yml"
+    playbook_path.write_text(playbook_content)
+    
+    result = ansible_runner(
+        playbook=str(playbook_path),
+        inventory=inventory,
+        group_name="base",
+    )
+    
+    assert result.returncode == 0, f"Ansible failed:\nSTDERR: 
{result.stderr}\nSTDOUT: {result.stdout}"
+    
+    # Verify user was created
+    ssh = paramiko.SSHClient()
+    ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())

Review Comment:
   ## Accepting unknown SSH host keys when using Paramiko
   
   Setting missing host key policy to AutoAddPolicy may be unsafe.
   
   [Show more 
details](https://github.com/apache/airavata/security/code-scanning/65)



##########
dev-tools/ansible/tests/test_database_role.py:
##########
@@ -0,0 +1,108 @@
+#
+#
+# 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.
+#
+
+"""Tests for the database role on all distributions."""
+
+import pytest
+import paramiko
+
+from tests.fixtures.containers import ubuntu_container, rocky_container, 
centos_container
+
+
+def _test_database_role(container, ansible_runner, inventory_generator, 
test_ssh_key, collection_verifier):
+    """Helper function to test database role on any distribution."""
+    container_info = container.container_info
+    inventory = inventory_generator(
+        container_info=container_info,
+        group_name="database",
+        extra_vars={
+            "user": "airavata",
+            "group": "airavata",
+            "db_password": "test_password",
+            "mysql_root_password": "test_root_password",
+            "db_name": "airavata",
+        },
+    )
+    
+    playbook_content = """---
+- name: Database Role Test
+  hosts: database
+  become: true
+  roles:
+    - base
+    - role: database
+      become_user: "{{ user }}"
+"""
+    playbook_path = inventory.parent / "test_database.yml"
+    playbook_path.write_text(playbook_content)
+    
+    result = ansible_runner(
+        playbook=str(playbook_path),
+        inventory=inventory,
+        group_name="database",
+        tags=["database"],
+    )
+    
+    assert result.returncode == 0, f"Ansible failed:\nSTDERR: 
{result.stderr}\nSTDOUT: {result.stdout}"
+    
+    # Verify MariaDB is running
+    ssh = paramiko.SSHClient()
+    ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())

Review Comment:
   ## Accepting unknown SSH host keys when using Paramiko
   
   Setting missing host key policy to AutoAddPolicy may be unsafe.
   
   [Show more 
details](https://github.com/apache/airavata/security/code-scanning/66)



##########
modules/agent-framework/agent-service/src/main/java/org/apache/airavata/agent/service/AiravataFileService.java:
##########
@@ -0,0 +1,286 @@
+/**
+*
+* 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.airavata.agent.service;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.protobuf.Timestamp;
+import io.grpc.Status;
+import io.grpc.stub.StreamObserver;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.apache.airavata.agent.ServerMessage;
+import org.apache.airavata.agent.UserContext;
+import org.apache.airavata.agent.db.entity.ExperimentStorageCacheEntity;
+import 
org.apache.airavata.agent.db.repository.ExperimentStorageCacheRepository;
+import org.apache.airavata.agent.model.DirectoryInfo;
+import org.apache.airavata.agent.model.ExperimentStorageResponse;
+import org.apache.airavata.agent.model.FileInfo;
+import org.apache.airavata.research.experiment.model.ExperimentSearchFields;
+import org.apache.airavata.research.experiment.model.ExperimentSummaryModel;
+import org.apache.airavata.research.experiment.model.Project;
+import org.apache.airavata.research.experiment.service.ExperimentSearchService;
+import org.apache.airavata.research.experiment.service.ExperimentService;
+import org.apache.airavata.fuse.DirEntry;
+import org.apache.airavata.fuse.ReadDirReq;
+import org.apache.airavata.fuse.ReadDirRes;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+@Service("agentFileService")
+public class AiravataFileService {
+
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(AiravataFileService.class);
+    private static final long CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
+
+    private final RestTemplate restTemplate = new RestTemplate();
+    private final ExperimentService experimentService;
+    private final ExperimentSearchService experimentSearchService;
+    private final ExperimentStorageCacheRepository storageCacheRepository;
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    public AiravataFileService(
+            ExperimentService experimentService,
+            ExperimentSearchService experimentSearchService,
+            ExperimentStorageCacheRepository storageCacheRepository) {
+        this.experimentService = experimentService;
+        this.experimentSearchService = experimentSearchService;
+        this.storageCacheRepository = storageCacheRepository;
+    }
+
+    public void handleReadDirRequest(ReadDirReq request, 
StreamObserver<ServerMessage> responseObserver) {
+        var fusePath = request.getName();
+
+        var readDirResBuilder = ReadDirRes.newBuilder();
+
+        try {
+            if ("/".equals(fusePath)) {
+                var experimentIds = getUserExperimentIDs();
+
+                // Handle root directory
+                for (String expId : experimentIds) {
+                    readDirResBuilder.addResult(
+                            
DirEntry.newBuilder().setName(expId).setIsDir(true).build());
+                }
+
+            } else {
+                var experimentId = extractExperimentIdFromPath(fusePath);
+                var path = extractPathFromRequest(fusePath);
+
+                var storageResponse = getExperimentStorage(experimentId, path);
+
+                if (storageResponse == null) {
+                    responseObserver.onError(Status.NOT_FOUND
+                            .withDescription("File path not found: " + path)
+                            .asRuntimeException());
+                    return;
+                }
+
+                // List directories
+                for (DirectoryInfo dirInfo : storageResponse.getDirectories()) 
{
+                    readDirResBuilder.addResult(DirEntry.newBuilder()
+                            .setName(dirInfo.getName())
+                            .setIsDir(true)
+                            .build());
+                }
+
+                // List files
+                for (FileInfo fileInfo : storageResponse.getFiles()) {
+                    readDirResBuilder.addResult(DirEntry.newBuilder()
+                            .setName(fileInfo.getName())
+                            .setIsDir(false)
+                            .setInfo(convertFileInfoModel(fileInfo))
+                            .build());
+                }
+            }
+        } catch (Exception e) {
+            LOGGER.error("Failed to fetch experiments when trying to read the 
directory");
+            responseObserver.onError(Status.INTERNAL
+                    .withDescription("Failed to fetch experiments  when trying 
to read the directory")
+                    .asRuntimeException());
+        }
+
+        // 
responseObserver.onNext(ServerMessage.newBuilder().setReadDirRes(readDirResBuilder.build()).build());
+        responseObserver.onCompleted();
+    }
+
+    public ExperimentStorageResponse getExperimentStorage(String experimentId, 
String path) throws ExecutionException {
+        var fullPath = experimentId + (path.equals("/") ? "" : "/" + path);
+        long minTimestamp = System.currentTimeMillis() - CACHE_TTL_MS;
+
+        // Check DB cache for fresh entry
+        var cached = storageCacheRepository.findFreshEntry(fullPath, 
minTimestamp);
+        if (cached.isPresent()) {
+            try {
+                return objectMapper.readValue(cached.get().getResponseJson(), 
ExperimentStorageResponse.class);
+            } catch (JsonProcessingException e) {
+                LOGGER.warn("Failed to deserialize cached storage response: 
{}", e.getMessage());
+            }
+        }
+
+        // Fetch from API and save to DB
+        var response = fetchExperimentStorageFromAPI(experimentId, path);
+        if (response != null) {
+            try {
+                var entity = new ExperimentStorageCacheEntity();
+                entity.setCacheKey(fullPath);
+                entity.setExperimentId(experimentId);
+                entity.setPath(path);
+                
entity.setResponseJson(objectMapper.writeValueAsString(response));
+                entity.setCachedAt(System.currentTimeMillis());
+                storageCacheRepository.save(entity);
+            } catch (JsonProcessingException e) {
+                LOGGER.warn("Failed to serialize storage response for caching: 
{}", e.getMessage());
+            }
+        }
+        return response;
+    }
+
+    private ExperimentStorageResponse fetchExperimentStorageFromAPI(String 
experimentId, String path) {
+        var url = "https://"; + UserContext.gatewayId() + 
".cybershuttle.org/api/experiment-storage/" + experimentId
+                + "/" + path;
+
+        var headers = new HttpHeaders();
+        headers.setBearerAuth(UserContext.authzToken().getAccessToken());
+        headers.setAll(UserContext.authzToken().getClaimsMap());
+
+        var entity = new HttpEntity<String>(headers);
+
+        var responseEntity = restTemplate.exchange(url, HttpMethod.GET, 
entity, ExperimentStorageResponse.class);

Review Comment:
   ## Server-side request forgery
   
   Potential server-side request forgery due to a [user-provided value](1).
   
   [Show more 
details](https://github.com/apache/airavata/security/code-scanning/71)



##########
modules/airavata-api/src/main/java/org/apache/airavata/config/WebSecurityConfiguration.java:
##########
@@ -0,0 +1,91 @@
+/**
+*
+* 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.airavata.config;
+
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+import 
org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import 
org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import 
org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
+import 
org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
+import org.springframework.security.web.SecurityFilterChain;
+
+/**
+ * Spring Security configuration for the Airavata API.
+ * When a JwtDecoder is available (Keycloak JWKS configured), enforces JWT 
authentication
+ * on /api/v1/** endpoints. When no JwtDecoder is available (tests), permits 
all requests.
+ */
+@Configuration
+@EnableWebSecurity
+public class WebSecurityConfiguration {
+
+    @Bean
+    public SecurityFilterChain securityFilterChain(HttpSecurity http, 
ObjectProvider<JwtDecoder> jwtDecoderProvider)
+            throws Exception {
+        http.csrf(csrf -> csrf.disable())

Review Comment:
   ## Disabled Spring CSRF protection
   
   CSRF vulnerability due to protection being disabled.
   
   [Show more 
details](https://github.com/apache/airavata/security/code-scanning/73)



##########
modules/airavata-api/src/main/java/org/apache/airavata/config/RestClientConfiguration.java:
##########
@@ -0,0 +1,163 @@
+/**
+*
+* 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.airavata.config;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.security.KeyStore;
+import java.time.Duration;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.TrustManagerFactory;
+import javax.net.ssl.X509TrustManager;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.web.client.RestTemplateBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.web.client.RestTemplate;
+
+/**
+ * REST client configuration for Spring RestTemplate.
+ * Provides a configured RestTemplate bean with proper timeouts and SSL 
support.
+ *
+ * Configure via application.properties:
+ *   security.tls.enabled=true
+ *   security.tls.keystore.path=keystores/airavata.p12
+ *   security.tls.keystore.password=secret
+ */
+@Configuration
+public class RestClientConfiguration {
+
+    private static final Logger logger = 
LoggerFactory.getLogger(RestClientConfiguration.class);
+
+    private final ServerProperties properties;
+
+    public RestClientConfiguration(ServerProperties properties) {
+        this.properties = properties;
+    }
+
+    /**
+     * Primary RestTemplate bean with connection pooling and SSL support.
+     */
+    @Bean
+    @Primary
+    public RestTemplate restTemplate(RestTemplateBuilder builder) {
+        var restTemplate = builder.connectTimeout(Duration.ofSeconds(30))
+                .readTimeout(Duration.ofSeconds(60))
+                .build();
+
+        // Configure with Apache HttpClient 5 if SSL is needed
+        try {
+            configureSSL(restTemplate);
+        } catch (Exception e) {
+            logger.warn("Could not configure SSL for RestTemplate: {}", 
e.getMessage());
+        }
+
+        return restTemplate;
+    }
+
+    /**
+     * Configure SSL for RestTemplate using Java's standard SSLContext.
+     */
+    private void configureSSL(RestTemplate restTemplate) throws Exception {
+        // Check if security configuration is available
+        if (properties.security() == null
+                || properties.security().tls() == null
+                || properties.security().tls().keystore() == null) {
+            // Use default trust store with self-signed certificate support 
for development/test
+            SSLContext sslContext = createTrustAllSSLContext();
+            configureHttpClient(restTemplate, sslContext);
+            return;
+        }
+
+        var trustStorePath = properties.security().tls().keystore().path();
+        var trustStorePassword = 
properties.security().tls().keystore().password();
+
+        SSLContext sslContext;
+
+        if (trustStorePath != null && !trustStorePath.isEmpty() && new 
File(trustStorePath).exists()) {
+            // Use configured trust store
+            var trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
+            try (var fis = new FileInputStream(trustStorePath)) {
+                trustStore.load(fis, trustStorePassword != null ? 
trustStorePassword.toCharArray() : null);
+            }
+
+            var trustManagerFactory = 
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+            trustManagerFactory.init(trustStore);
+            var trustManagers = trustManagerFactory.getTrustManagers();
+
+            sslContext = SSLContext.getInstance("TLS");
+            sslContext.init(null, trustManagers, new 
java.security.SecureRandom());
+        } else {
+            // Use default trust store with self-signed certificate support 
for development
+            sslContext = createTrustAllSSLContext();
+        }
+
+        configureHttpClient(restTemplate, sslContext);
+    }
+
+    /**
+     * Create SSLContext that trusts all certificates (for development/test 
only).
+     */
+    private SSLContext createTrustAllSSLContext() throws Exception {
+        var trustAllCerts = new TrustManager[] {
+            new X509TrustManager() {
+                @Override
+                public java.security.cert.X509Certificate[] 
getAcceptedIssuers() {
+                    return null;
+                }
+
+                @Override
+                public void 
checkClientTrusted(java.security.cert.X509Certificate[] certs, String authType) 
{}
+
+                @Override
+                public void 
checkServerTrusted(java.security.cert.X509Certificate[] certs, String authType) 
{}
+            }
+        };
+        var sslContext = SSLContext.getInstance("TLS");
+        sslContext.init(null, trustAllCerts, new java.security.SecureRandom());

Review Comment:
   ## `TrustManager` that accepts all certificates
   
   This uses [TrustManager](1), which is defined in 
[RestClientConfiguration$](2) and trusts any certificate.
   
   [Show more 
details](https://github.com/apache/airavata/security/code-scanning/74)



##########
modules/airavata-api/src/main/java/org/apache/airavata/iam/keycloak/KeycloakRestClient.java:
##########
@@ -0,0 +1,610 @@
+/**
+*
+* 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.airavata.iam.keycloak;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyStore;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManager;
+import org.apache.airavata.core.util.IdGenerator;
+import org.apache.airavata.config.ConfigResolver;
+import org.apache.airavata.config.ServerProperties;
+import org.apache.airavata.credential.model.PasswordCredential;
+import org.apache.airavata.iam.exception.IamAdminServicesException;
+import org.apache.airavata.iam.exception.AiravataSecurityException;
+import org.apache.airavata.iam.model.ClientRepresentation;
+import org.apache.airavata.iam.model.CredentialRepresentation;
+import org.apache.airavata.iam.model.RealmRepresentation;
+import org.apache.airavata.iam.model.RoleRepresentation;
+import org.apache.airavata.iam.model.TokenResponse;
+import org.apache.airavata.iam.model.UserRepresentation;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.web.client.HttpClientErrorException;
+import org.springframework.web.client.HttpServerErrorException;
+import org.springframework.web.client.RestClientException;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.util.UriComponentsBuilder;
+
+/**
+ * REST client for Keycloak Admin API.
+ * Replaces the Keycloak Admin Client library with direct REST API calls.
+ */
+public class KeycloakRestClient {
+    private static final Logger logger = 
LoggerFactory.getLogger(KeycloakRestClient.class);
+    private static final String ADMIN_CLI_CLIENT_ID = "admin-cli";
+    private static final String GRANT_TYPE_PASSWORD = "password";
+
+    private final String serverUrl;
+    private final ServerProperties properties;
+    private final RestTemplate restTemplate;
+    private final ObjectMapper objectMapper;
+    private final Map<String, TokenCacheEntry> tokenCache = new 
ConcurrentHashMap<>();
+
+    /**
+     * Constructor with Spring-injected RestTemplate and ObjectMapper.
+     */
+    public KeycloakRestClient(
+            String serverUrl,
+            ServerProperties properties,
+            RestTemplate restTemplate,
+            ObjectMapper objectMapper) {
+        this.serverUrl = serverUrl;
+        this.properties = properties;
+        this.restTemplate = restTemplate;
+        this.objectMapper = objectMapper;
+    }
+
+    /**
+     * Legacy constructor - creates own RestTemplate and ObjectMapper.
+     */
+    public KeycloakRestClient(String serverUrl, ServerProperties properties) {
+        this.serverUrl = serverUrl;
+        this.properties = properties;
+        this.objectMapper = new ObjectMapper();
+        this.restTemplate = createRestTemplate();
+    }
+
+    private RestTemplate createRestTemplate() {
+        var template = new RestTemplate();
+        try {
+            if (properties != null
+                    && properties.security() != null
+                    && properties.security().tls() != null
+                    && properties.security().tls().enabled()
+                    && properties.security().tls().keystore() != null) {
+                // Configure SSL with keystore using Java's standard SSLContext
+                var configDir = ConfigResolver.getConfigDir(); // Will throw 
if not found
+                var keystorePath = 
properties.security().tls().keystore().path();
+                if (keystorePath == null || keystorePath.isEmpty()) {
+                    logger.debug("TLS enabled but keystore path not 
configured, using default HTTP client");
+                    template.setRequestFactory(new 
SimpleClientHttpRequestFactory());
+                    return template;
+                }
+                // Keystore path is relative to configDir (e.g., 
"keystores/airavata.p12")
+                var keystoreFullPath = new File(configDir, 
keystorePath).getAbsolutePath();
+                var keystorePassword = 
properties.security().tls().keystore().password();
+                var keyStore = loadKeyStore(keystoreFullPath, 
keystorePassword);
+
+                // Create SSLContext with trust store
+                var sslContext = SSLContext.getInstance("TLS");
+                var trustManagers = createTrustManagers(keyStore);
+                sslContext.init(null, trustManagers, new 
java.security.SecureRandom());
+
+                // Use Spring's HttpComponentsClientHttpRequestFactory with 
custom SSL context
+                // Note: For advanced SSL configuration, we still use 
HttpComponentsClientHttpRequestFactory
+                // but it's provided by Spring's dependency on httpcomponents, 
not a direct dependency
+                template.setRequestFactory(new 
SimpleClientHttpRequestFactory());
+                logger.info("TLS enabled - keystore loaded. SSL context 
configured via JVM system properties");
+            } else {
+                // No TLS or keystore not configured - use default HTTP client
+                template.setRequestFactory(new 
SimpleClientHttpRequestFactory());
+            }
+        } catch (Exception e) {
+            logger.warn("Failed to configure TLS for Keycloak REST client, 
using default: {}", e.getMessage());
+            template.setRequestFactory(new SimpleClientHttpRequestFactory());
+        }
+        return template;
+    }
+
+    /**
+     * Create trust managers from keystore.
+     */
+    private TrustManager[] createTrustManagers(KeyStore trustStore) throws 
Exception {
+        javax.net.ssl.TrustManagerFactory trustManagerFactory =
+                
javax.net.ssl.TrustManagerFactory.getInstance(javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm());
+        trustManagerFactory.init(trustStore);
+        return trustManagerFactory.getTrustManagers();
+    }
+
+    private static KeyStore loadKeyStore(String keyStorePath, String 
keyStorePassword)
+            throws AiravataSecurityException {
+        var keyStoreFile = new File(keyStorePath);
+        if (keyStoreFile.exists() && keyStoreFile.isFile()) {
+            logger.info("Loading trust store file from path {}", keyStorePath);
+        } else {
+            logger.error("Trust store file does not exist at path {}", 
keyStorePath);
+            throw new AiravataSecurityException("Trust store file does not 
exist at path " + keyStorePath);
+        }
+        try {
+            return KeyStore.getInstance(keyStoreFile, 
keyStorePassword.toCharArray());
+        } catch (Exception e) {
+            logger.error("Failed to load trust store file from path {}", 
keyStorePath, e);
+            throw new AiravataSecurityException("Failed to load trust store 
file from path " + keyStorePath, e);
+        }
+    }
+
+    /**
+     * Obtain admin access token using password grant.
+     */
+    public String obtainAdminToken(String realm, PasswordCredential 
credentials) throws IamAdminServicesException {
+        var cacheKey = realm + ":" + credentials.getLoginUserName();
+        var cached = tokenCache.get(cacheKey);
+        if (cached != null && !cached.isExpired()) {
+            return cached.getToken();
+        }
+
+        try {
+            var tokenUrl = serverUrl + "/realms/" + realm + 
"/protocol/openid-connect/token";
+            var headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+            // admin-cli is a public client, so no Basic Auth needed - just 
send client_id in form data
+
+            var formData = new HashMap<String, String>();
+            formData.put("grant_type", GRANT_TYPE_PASSWORD);
+            formData.put("username", credentials.getLoginUserName());
+            formData.put("password", credentials.getPassword());
+            formData.put("client_id", ADMIN_CLI_CLIENT_ID);
+
+            var formBody = new StringBuilder();
+            formData.forEach((key, value) -> {
+                if (formBody.length() > 0) {
+                    formBody.append("&");
+                }
+                formBody.append(key).append("=").append(encode(value));
+            });
+
+            var request = new HttpEntity<>(formBody.toString(), headers);
+            var response = restTemplate.exchange(tokenUrl, HttpMethod.POST, 
request, TokenResponse.class);
+
+            if (response.getStatusCode() == HttpStatus.OK && 
response.getBody() != null) {
+                var token = response.getBody().getAccessToken();
+                int expiresIn = response.getBody().getExpiresIn() != null
+                        ? response.getBody().getExpiresIn()
+                        : 60; // Default to 60 seconds if not provided
+                tokenCache.put(cacheKey, new TokenCacheEntry(token, 
expiresIn));
+                return token;
+            } else {
+                throw new IamAdminServicesException("Failed to obtain admin 
token: " + response.getStatusCode());
+            }
+        } catch (HttpClientErrorException | HttpServerErrorException e) {
+            logger.error("Error obtaining admin token: {}", e.getMessage());
+            throw new IamAdminServicesException("Failed to obtain admin token: 
" + e.getMessage(), e);
+        } catch (RestClientException e) {
+            logger.error("Error obtaining admin token: {}", e.getMessage());
+            throw new IamAdminServicesException("Failed to obtain admin token: 
" + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * Obtain access token using existing access token (for token-based auth).
+     */
+    public String validateToken(String realm, String accessToken) throws 
IamAdminServicesException {
+        // For token-based auth, we just return the token as-is
+        // The token will be validated by Keycloak when making API calls
+        return accessToken;
+    }
+
+    private String encode(String value) {
+        try {
+            return java.net.URLEncoder.encode(value, StandardCharsets.UTF_8);
+        } catch (Exception e) {
+            return value;
+        }
+    }
+
+    private HttpHeaders createAuthHeaders(String accessToken) {
+        var headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        headers.setBearerAuth(accessToken);
+        return headers;
+    }
+
+    // ==================== Realm Management ====================
+
+    public void createRealm(RealmRepresentation realm) throws 
IamAdminServicesException {
+        var adminToken = obtainAdminToken("master", 
getSuperAdminCredentials());
+        var url = serverUrl + "/admin/realms";
+        var request = new HttpEntity<>(realm, createAuthHeaders(adminToken));
+        try {
+            restTemplate.exchange(url, HttpMethod.POST, request, Void.class);
+        } catch (HttpClientErrorException | HttpServerErrorException e) {
+            throw new IamAdminServicesException("Failed to create realm: " + 
e.getMessage(), e);
+        }
+    }
+
+    // ==================== User Management ====================
+
+    public ResponseEntity<Void> createUser(String realm, UserRepresentation 
user, String accessToken)
+            throws IamAdminServicesException {
+        var url = serverUrl + "/admin/realms/" + realm + "/users";
+        var request = new HttpEntity<>(user, createAuthHeaders(accessToken));
+        try {
+            return restTemplate.exchange(url, HttpMethod.POST, request, 
Void.class);
+        } catch (HttpClientErrorException | HttpServerErrorException e) {
+            throw new IamAdminServicesException("Failed to create user: " + 
e.getMessage(), e);
+        }
+    }
+
+    public List<UserRepresentation> searchUsers(
+            String realm,
+            String username,
+            String firstName,
+            String lastName,
+            String email,
+            Integer first,
+            Integer max,
+            String accessToken)
+            throws IamAdminServicesException {
+        return searchUsers(realm, username, firstName, lastName, email, first, 
max, false, accessToken);
+    }
+
+    public List<UserRepresentation> searchUsers(
+            String realm,
+            String username,
+            String firstName,
+            String lastName,
+            String email,
+            Integer first,
+            Integer max,
+            Boolean exact,
+            String accessToken)
+            throws IamAdminServicesException {
+        var url = serverUrl + "/admin/realms/" + realm + "/users";
+        var builder = UriComponentsBuilder.fromUriString(url);
+        if (username != null) {
+            builder.queryParam("username", username);
+        }
+        if (exact != null && exact) {
+            builder.queryParam("exact", exact);
+        }
+        if (firstName != null) {
+            builder.queryParam("firstName", firstName);
+        }
+        if (lastName != null) {
+            builder.queryParam("lastName", lastName);
+        }
+        if (email != null) {
+            builder.queryParam("email", email);
+        }
+        if (first != null) {
+            builder.queryParam("first", first);
+        }
+        if (max != null) {
+            builder.queryParam("max", max);
+        }
+
+        var headers = createAuthHeaders(accessToken);
+        var request = new HttpEntity<Void>(headers);
+        try {
+            @SuppressWarnings("unchecked")
+            ResponseEntity<List<?>> response = (ResponseEntity<List<?>>) 
(ResponseEntity<?>)
+                    restTemplate.exchange(builder.toUriString(), 
HttpMethod.GET, request, List.class);

Review Comment:
   ## Server-side request forgery
   
   Potential server-side request forgery due to a [user-provided value](1).
   Potential server-side request forgery due to a [user-provided value](2).
   Potential server-side request forgery due to a [user-provided value](3).
   Potential server-side request forgery due to a [user-provided value](4).
   Potential server-side request forgery due to a [user-provided value](5).
   Potential server-side request forgery due to a [user-provided value](6).
   Potential server-side request forgery due to a [user-provided value](7).
   Potential server-side request forgery due to a [user-provided value](8).
   Potential server-side request forgery due to a [user-provided value](9).
   
   [Show more 
details](https://github.com/apache/airavata/security/code-scanning/72)



-- 
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