diff --git a/README.md b/README.md index 9d9efbe9..58106c41 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ sub-projects: core – Handles the core functionalities (e.g., SSH session management, zero trust policy enforcement). api – Provides a RESTful API layer to interface with the core module. dataplane – Offers dataplane functionality for secure data transfer and processing. - llm-proxy – A proxy service that integrates with large language models (LLMs) to enhance security and compliance in SSH sessions. + integration-proxy – A proxy service that integrates with large language models (LLMs) to enhance security and compliance in SSH sessions. llm-dataplane – A data processing layer that leverages LLMs for advanced analysis and decision-making in SSH sessions. ops-scripts – Contains operational scripts for deployment and management tasks. ai-agent – Java-based intelligent agent framework for monitoring and controlling SSH sessions. diff --git a/build-images.sh b/build-images.sh index b00794e1..75c782b8 100755 --- a/build-images.sh +++ b/build-images.sh @@ -74,7 +74,7 @@ update_sentrius_ssh=false update_sentrius_keycloak=false update_sentrius_agent=false update_sentrius_ai_agent=false -update_llmproxy=false +update_integrationproxy=false update_launcher=false while [[ "$#" -gt 0 ]]; do @@ -85,8 +85,8 @@ while [[ "$#" -gt 0 ]]; do --sentrius-agent) update_sentrius_agent=true ;; --sentrius-ai-agent) update_sentrius_ai_agent=true ;; --sentrius-launcher-service) update_launcher=true ;; - --sentrius-llmproxy) update_llmproxy=true ;; - --all) update_sentrius=true; update_sentrius_ssh=true; update_sentrius_keycloak=true; update_sentrius_agent=true; update_sentrius_ai_agent=true; update_llmproxy=true; update_launcher=true ;; + --sentrius-integration-proxy) update_integrationproxy=true ;; + --all) update_sentrius=true; update_sentrius_ssh=true; update_sentrius_keycloak=true; update_sentrius_agent=true; update_sentrius_ai_agent=true; update_integrationproxy=true; update_launcher=true ;; --no-cache) NO_CACHE=true ;; *) echo "Unknown flag: $1"; exit 1 ;; esac @@ -138,11 +138,11 @@ if $update_sentrius_ai_agent; then rm docker/sentrius-launchable-agent/agent.jar fi -if $update_llmproxy; then - cp llm-proxy/target/sentrius-llm-proxy-*.jar docker/llmproxy/llmproxy.jar +if $update_integrationproxy; then + cp integration-proxy/target/sentrius-integration-proxy-*.jar docker/integrationproxy/llmproxy.jar LLMPROXY_VERSION=$(increment_patch_version $LLMPROXY_VERSION) - build_image "sentrius-llmproxy" "$LLMPROXY_VERSION" "./docker/llmproxy" - rm docker/llmproxy/llmproxy.jar + build_image "sentrius-integration-proxy" "$LLMPROXY_VERSION" "./docker/integrationproxy" + rm docker/integrationproxy/llmproxy.jar update_env_var "LLMPROXY_VERSION" "$LLMPROXY_VERSION" fi diff --git a/deprecated-build-images-local.sh b/deprecated-build-images-local.sh index 95ab7f70..30b00e94 100755 --- a/deprecated-build-images-local.sh +++ b/deprecated-build-images-local.sh @@ -59,7 +59,7 @@ update_sentrius_ssh=false update_sentrius_keycloak=false update_sentrius_agent=false update_sentrius_ai_agent=false -update_llmproxy=false +update_integrationproxy=false no_cache=false # Default: use cache @@ -70,8 +70,8 @@ while [[ "$#" -gt 0 ]]; do --sentrius-keycloak) update_sentrius_keycloak=true ;; --sentrius-agent) update_sentrius_agent=true ;; --sentrius-ai-agent) update_sentrius_ai_agent=true ;; - --sentrius-llmproxy) update_llmproxy=true ;; - --all) update_sentrius=true; update_sentrius_ssh=true; update_sentrius_keycloak=true; update_sentrius_agent=true; update_sentrius_ai_agent=true; update_llmproxy=true ;; + --sentrius-integration-proxy) update_integrationproxy=true ;; + --all) update_sentrius=true; update_sentrius_ssh=true; update_sentrius_keycloak=true; update_sentrius_agent=true; update_sentrius_ai_agent=true; update_integrationproxy=true ;; --no-cache) no_cache=true ;; # Set no_cache to true if the flag is passed *) echo "Unknown flag: $1"; exit 1 ;; esac @@ -140,14 +140,14 @@ if $update_sentrius_ai_agent; then #minikube image load sentrius-ai-agent:latest fi -if $update_llmproxy; then - cp llm-proxy/target/sentrius-llm-proxy-*.jar docker/llmproxy/llmproxy.jar +if $update_integrationproxy; then + cp integration-proxy/target/sentrius-integration-proxy-*.jar docker/integrationproxy/llmproxy.jar LLMPROXY_VERSION=$(increment_patch_version $LLMPROXY_VERSION) - build_image "sentrius-llmproxy" "$LLMPROXY_VERSION" "./docker/llmproxy" - rm docker/llmproxy/llmproxy.jar + build_image "sentrius-integration-proxy" "$LLMPROXY_VERSION" "./docker/integrationproxy" + rm docker/integrationproxy/llmproxy.jar update_env_var "LLMPROXY_VERSION" "$LLMPROXY_VERSION" ## for local, replace minikube with docker - docker tag sentrius-llmproxy:$LLMPROXY_VERSION sentrius-llmproxy:latest + docker tag sentrius-integration-proxy:$LLMPROXY_VERSION sentrius-integration-proxy:latest echo "Loading image into minikube" #minikube image load sentrius-ai-agent:LLMPROXY_VERSION #minikube image load sentrius-ai-agent:latest diff --git a/docker/llmproxy/Dockerfile b/docker/integrationproxy/Dockerfile similarity index 100% rename from docker/llmproxy/Dockerfile rename to docker/integrationproxy/Dockerfile diff --git a/llm-proxy/Gruntfile.js b/integration-proxy/Gruntfile.js similarity index 100% rename from llm-proxy/Gruntfile.js rename to integration-proxy/Gruntfile.js diff --git a/integration-proxy/JIRA_PROXY_API.md b/integration-proxy/JIRA_PROXY_API.md new file mode 100644 index 00000000..cd1958f4 --- /dev/null +++ b/integration-proxy/JIRA_PROXY_API.md @@ -0,0 +1,163 @@ +# JIRA Proxy API Documentation + +The JIRA Proxy Controller provides a secure interface to interact with JIRA instances through the Sentrius platform. It mirrors key JIRA REST API endpoints while maintaining the platform's authentication and authorization mechanisms. + +## Overview + +The JIRA proxy is implemented in the `integration-proxy` module and provides authenticated access to JIRA functionality for agents and compliance tools. It follows the same security patterns as the existing OpenAI proxy. + +## Authentication + +All endpoints require: +- Valid JWT token in the `Authorization` header (format: `Bearer `) +- User must have `CAN_LOG_IN` application access +- At least one JIRA integration must be configured in the system + +## Endpoints + +### 1. Search Issues + +**GET** `/api/v1/jira/rest/api/3/search` + +Search for JIRA issues using JQL or simple text queries. + +**Parameters:** +- `jql` (optional): JIRA Query Language string +- `query` (optional): Simple text search query + +**Example:** +```bash +curl -X GET \ + "https://your-instance/api/v1/jira/rest/api/3/search?query=bug" \ + -H "Authorization: Bearer " +``` + +**Response:** Array of TicketDTO objects containing issue information. + +### 2. Get Issue + +**GET** `/api/v1/jira/rest/api/3/issue/{issueKey}` + +Retrieve information about a specific JIRA issue. + +**Parameters:** +- `issueKey` (path): JIRA issue key (e.g., "PROJECT-123") + +**Example:** +```bash +curl -X GET \ + "https://your-instance/api/v1/jira/rest/api/3/issue/PROJECT-123" \ + -H "Authorization: Bearer " +``` + +**Response:** Issue status information. + +### 3. Add Comment + +**POST** `/api/v1/jira/rest/api/3/issue/{issueKey}/comment` + +Add a comment to a JIRA issue. + +**Parameters:** +- `issueKey` (path): JIRA issue key +- Request body: Comment object with `text` or `body` field + +**Example:** +```bash +curl -X POST \ + "https://your-instance/api/v1/jira/rest/api/3/issue/PROJECT-123/comment" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"text": "This is a comment from the compliance agent"}' +``` + +**Response:** Success/failure message. + +### 4. Assign Issue + +**PUT** `/api/v1/jira/rest/api/3/issue/{issueKey}/assignee` + +Assign a JIRA issue to a user. + +**Parameters:** +- `issueKey` (path): JIRA issue key +- Request body: Assignee object with `accountId` field + +**Example:** +```bash +curl -X PUT \ + "https://your-instance/api/v1/jira/rest/api/3/issue/PROJECT-123/assignee" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"accountId": "user-account-id"}' +``` + +**Response:** HTTP 204 (No Content) on success. + +## Configuration + +### JIRA Integration Setup + +Before using the proxy, ensure a JIRA integration is configured: + +1. Use the existing `/api/v1/integrations/jira/add` endpoint to add JIRA integration +2. Provide required fields: `baseUrl`, `username`, `apiToken` + +### Security Model + +The proxy uses the existing security infrastructure: +- JWT validation through Keycloak +- User authentication via `BaseController.getOperatingUser()` +- Access control through `@LimitAccess` annotations +- OpenTelemetry tracing for monitoring + +## Implementation Details + +### Error Handling + +- **401 Unauthorized**: Invalid or missing JWT token +- **404 Not Found**: No JIRA integration configured +- **400 Bad Request**: Missing required parameters +- **500 Internal Server Error**: JIRA operation failed + +### Integration Token Selection + +Currently, the proxy uses the first available JIRA integration found for the connection type "jira". In production environments, you may want to extend this to allow users to specify which integration to use. + +### Tracing + +All operations are traced using OpenTelemetry with the tracer name `io.sentrius.sso`. Trace spans include: +- Operation type (search, get-issue, add-comment, assign-issue) +- Query parameters +- Result counts +- Success/failure status + +## Future Enhancements + +1. **Multi-integration Support**: Allow specifying which JIRA instance to use +2. **Enhanced JQL Support**: Full JQL query validation and optimization +3. **Bulk Operations**: Support for bulk issue updates and assignments +4. **Webhook Support**: Real-time notifications from JIRA +5. **Custom Field Support**: Access to JIRA custom fields +6. **Project-specific Operations**: Project creation, configuration management + +## Usage with Compliance Agents + +This proxy is designed to support compliance agents that need to: +- Search for compliance-related issues +- Create comments with compliance findings +- Assign issues to appropriate team members +- Track compliance status across JIRA projects + +Example agent workflow: +1. Search for open compliance issues: `GET /api/v1/jira/rest/api/3/search?jql=project = COMPLIANCE AND status = Open` +2. Add compliance assessment: `POST /api/v1/jira/rest/api/3/issue/COMPLIANCE-123/comment` +3. Assign for remediation: `PUT /api/v1/jira/rest/api/3/issue/COMPLIANCE-123/assignee` + +## Testing + +Comprehensive test coverage is provided in `JiraProxyControllerTest.java`, including: +- Authentication validation +- Authorization checks +- Error handling scenarios +- Request/response validation \ No newline at end of file diff --git a/llm-proxy/dynamic.properties b/integration-proxy/dynamic.properties similarity index 100% rename from llm-proxy/dynamic.properties rename to integration-proxy/dynamic.properties diff --git a/llm-proxy/package.json b/integration-proxy/package.json similarity index 100% rename from llm-proxy/package.json rename to integration-proxy/package.json diff --git a/llm-proxy/pom.xml b/integration-proxy/pom.xml similarity index 99% rename from llm-proxy/pom.xml rename to integration-proxy/pom.xml index a4fc8a48..d5895715 100644 --- a/llm-proxy/pom.xml +++ b/integration-proxy/pom.xml @@ -8,7 +8,7 @@ 1.0.0-SNAPSHOT - sentrius-llm-proxy + sentrius-integration-proxy diff --git a/llm-proxy/src/main/java/io/sentrius/sso/LLMProxyApplication.java b/integration-proxy/src/main/java/io/sentrius/sso/LLMProxyApplication.java similarity index 100% rename from llm-proxy/src/main/java/io/sentrius/sso/LLMProxyApplication.java rename to integration-proxy/src/main/java/io/sentrius/sso/LLMProxyApplication.java diff --git a/llm-proxy/src/main/java/io/sentrius/sso/config/AsyncConfig.java b/integration-proxy/src/main/java/io/sentrius/sso/config/AsyncConfig.java similarity index 100% rename from llm-proxy/src/main/java/io/sentrius/sso/config/AsyncConfig.java rename to integration-proxy/src/main/java/io/sentrius/sso/config/AsyncConfig.java diff --git a/llm-proxy/src/main/java/io/sentrius/sso/config/GlobalExceptionHandler.java b/integration-proxy/src/main/java/io/sentrius/sso/config/GlobalExceptionHandler.java similarity index 100% rename from llm-proxy/src/main/java/io/sentrius/sso/config/GlobalExceptionHandler.java rename to integration-proxy/src/main/java/io/sentrius/sso/config/GlobalExceptionHandler.java diff --git a/llm-proxy/src/main/java/io/sentrius/sso/config/HttpsRedirectConfig.java b/integration-proxy/src/main/java/io/sentrius/sso/config/HttpsRedirectConfig.java similarity index 100% rename from llm-proxy/src/main/java/io/sentrius/sso/config/HttpsRedirectConfig.java rename to integration-proxy/src/main/java/io/sentrius/sso/config/HttpsRedirectConfig.java diff --git a/llm-proxy/src/main/java/io/sentrius/sso/config/SchedulingConfig.java b/integration-proxy/src/main/java/io/sentrius/sso/config/SchedulingConfig.java similarity index 100% rename from llm-proxy/src/main/java/io/sentrius/sso/config/SchedulingConfig.java rename to integration-proxy/src/main/java/io/sentrius/sso/config/SchedulingConfig.java diff --git a/llm-proxy/src/main/java/io/sentrius/sso/config/SecurityConfig.java b/integration-proxy/src/main/java/io/sentrius/sso/config/SecurityConfig.java similarity index 100% rename from llm-proxy/src/main/java/io/sentrius/sso/config/SecurityConfig.java rename to integration-proxy/src/main/java/io/sentrius/sso/config/SecurityConfig.java diff --git a/integration-proxy/src/main/java/io/sentrius/sso/controllers/api/JiraProxyController.java b/integration-proxy/src/main/java/io/sentrius/sso/controllers/api/JiraProxyController.java new file mode 100644 index 00000000..a33ad8a6 --- /dev/null +++ b/integration-proxy/src/main/java/io/sentrius/sso/controllers/api/JiraProxyController.java @@ -0,0 +1,328 @@ +package io.sentrius.sso.controllers.api; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import com.fasterxml.jackson.core.JsonProcessingException; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; +import io.sentrius.sso.config.ApplicationConfig; +import io.sentrius.sso.core.annotations.LimitAccess; +import io.sentrius.sso.core.config.SystemOptions; +import io.sentrius.sso.core.controllers.BaseController; +import io.sentrius.sso.core.dto.TicketDTO; +import io.sentrius.sso.core.integrations.ticketing.JiraService; +import io.sentrius.sso.core.model.security.IntegrationSecurityToken; +import io.sentrius.sso.core.model.security.enums.ApplicationAccessEnum; +import io.sentrius.sso.core.services.ErrorOutputService; +import io.sentrius.sso.core.services.UserService; +import io.sentrius.sso.core.services.security.IntegrationSecurityTokenService; +import io.sentrius.sso.core.services.security.KeycloakService; +import io.sentrius.sso.integrations.exceptions.HttpException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.http.HttpStatus; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.client.RestTemplate; + +@RestController +@RequestMapping("/api/v1/jira") +@Slf4j +public class JiraProxyController extends BaseController { + + final KeycloakService keycloakService; + final IntegrationSecurityTokenService integrationSecurityTokenService; + final RestTemplateBuilder restTemplateBuilder; + final ApplicationConfig applicationConfig; + + Tracer tracer = GlobalOpenTelemetry.getTracer("io.sentrius.sso"); + + protected JiraProxyController( + UserService userService, + SystemOptions systemOptions, + ErrorOutputService errorOutputService, + KeycloakService keycloakService, + IntegrationSecurityTokenService integrationSecurityTokenService, + RestTemplateBuilder restTemplateBuilder, + ApplicationConfig applicationConfig + ) { + super(userService, systemOptions, errorOutputService); + this.keycloakService = keycloakService; + this.integrationSecurityTokenService = integrationSecurityTokenService; + this.restTemplateBuilder = restTemplateBuilder; + this.applicationConfig = applicationConfig; + } + + @GetMapping("/rest/api/3/search") + @LimitAccess(applicationAccess = {ApplicationAccessEnum.CAN_LOG_IN}) + public ResponseEntity search( + @RequestHeader("Authorization") String token, + @RequestParam(value = "jql", required = false) String jql, + @RequestParam(value = "query", required = false) String query, + HttpServletRequest request, + HttpServletResponse response + ) throws JsonProcessingException, HttpException { + + Span span = tracer.spanBuilder("jira-proxy-search").startSpan(); + try (Scope scope = span.makeCurrent()) { + String compactJwt = token.startsWith("Bearer ") ? token.substring(7) : token; + + if (!keycloakService.validateJwt(compactJwt)) { + log.warn("Invalid Keycloak token"); + return ResponseEntity.status(HttpStatus.SC_UNAUTHORIZED).body("Invalid Keycloak token"); + } + + var operatingUser = getOperatingUser(request, response); + if (null == operatingUser) { + return ResponseEntity.status(HttpStatus.SC_UNAUTHORIZED).body("User not authenticated"); + } + + // Get the first available JIRA integration for the user + // In a production environment, you might want to allow specifying which integration to use + List jiraIntegrations = integrationSecurityTokenService + .findByConnectionType("jira"); + + if (jiraIntegrations.isEmpty()) { + return ResponseEntity.status(HttpStatus.SC_NOT_FOUND).body("No JIRA integration configured"); + } + + IntegrationSecurityToken jiraIntegration = jiraIntegrations.get(0); + JiraService jiraService = new JiraService(new RestTemplate(), jiraIntegration); + + // Use the query parameter if jql is not provided + String searchQuery = jql != null ? jql : query; + if (searchQuery == null) { + return ResponseEntity.badRequest().body("Either 'jql' or 'query' parameter is required"); + } + + List tickets = jiraService.searchForIncidents(searchQuery); + + span.setAttribute("search.query", searchQuery); + span.setAttribute("search.results.count", tickets.size()); + + return ResponseEntity.ok(tickets); + + } catch (ExecutionException | InterruptedException e) { + log.error("Error executing JIRA search", e); + throw new RuntimeException(e); + } finally { + span.end(); + } + } + + @GetMapping("/rest/api/3/issue/{issueKey}") + @LimitAccess(applicationAccess = {ApplicationAccessEnum.CAN_LOG_IN}) + public ResponseEntity getIssue( + @RequestHeader("Authorization") String token, + @PathVariable String issueKey, + HttpServletRequest request, + HttpServletResponse response + ) throws JsonProcessingException, HttpException { + + Span span = tracer.spanBuilder("jira-proxy-get-issue").startSpan(); + try (Scope scope = span.makeCurrent()) { + String compactJwt = token.startsWith("Bearer ") ? token.substring(7) : token; + + if (!keycloakService.validateJwt(compactJwt)) { + log.warn("Invalid Keycloak token"); + return ResponseEntity.status(HttpStatus.SC_UNAUTHORIZED).body("Invalid Keycloak token"); + } + + var operatingUser = getOperatingUser(request, response); + if (null == operatingUser) { + return ResponseEntity.status(HttpStatus.SC_UNAUTHORIZED).body("User not authenticated"); + } + + List jiraIntegrations = integrationSecurityTokenService + .findByConnectionType("jira"); + + if (jiraIntegrations.isEmpty()) { + return ResponseEntity.status(HttpStatus.SC_NOT_FOUND).body("No JIRA integration configured"); + } + + IntegrationSecurityToken jiraIntegration = jiraIntegrations.get(0); + JiraService jiraService = new JiraService(new RestTemplate(), jiraIntegration); + + boolean isActive = jiraService.isTicketActive(issueKey); + + span.setAttribute("issue.key", issueKey); + span.setAttribute("issue.active", isActive); + + return ResponseEntity.ok(new IssueStatusResponse(issueKey, isActive ? "Active" : "Inactive")); + + } finally { + span.end(); + } + } + + @PostMapping("/rest/api/3/issue/{issueKey}/comment") + @LimitAccess(applicationAccess = {ApplicationAccessEnum.CAN_LOG_IN}) + public ResponseEntity addComment( + @RequestHeader("Authorization") String token, + @PathVariable String issueKey, + @RequestBody CommentRequest commentRequest, + HttpServletRequest request, + HttpServletResponse response + ) throws JsonProcessingException, HttpException { + + Span span = tracer.spanBuilder("jira-proxy-add-comment").startSpan(); + try (Scope scope = span.makeCurrent()) { + String compactJwt = token.startsWith("Bearer ") ? token.substring(7) : token; + + if (!keycloakService.validateJwt(compactJwt)) { + log.warn("Invalid Keycloak token"); + return ResponseEntity.status(HttpStatus.SC_UNAUTHORIZED).body("Invalid Keycloak token"); + } + + var operatingUser = getOperatingUser(request, response); + if (null == operatingUser) { + return ResponseEntity.status(HttpStatus.SC_UNAUTHORIZED).body("User not authenticated"); + } + + List jiraIntegrations = integrationSecurityTokenService + .findByConnectionType("jira"); + + if (jiraIntegrations.isEmpty()) { + return ResponseEntity.status(HttpStatus.SC_NOT_FOUND).body("No JIRA integration configured"); + } + + IntegrationSecurityToken jiraIntegration = jiraIntegrations.get(0); + JiraService jiraService = new JiraService(new RestTemplate(), jiraIntegration); + + // Extract comment text from the request + String commentText = extractCommentText(commentRequest); + if (commentText == null || commentText.trim().isEmpty()) { + return ResponseEntity.badRequest().body("Comment text is required"); + } + + boolean success = jiraService.updateTicket(issueKey, commentText); + + span.setAttribute("issue.key", issueKey); + span.setAttribute("comment.success", success); + + if (success) { + return ResponseEntity.ok(new CommentResponse("Comment added successfully")); + } else { + return ResponseEntity.status(HttpStatus.SC_INTERNAL_SERVER_ERROR) + .body("Failed to add comment to issue"); + } + + } finally { + span.end(); + } + } + + @PutMapping("/rest/api/3/issue/{issueKey}/assignee") + @LimitAccess(applicationAccess = {ApplicationAccessEnum.CAN_LOG_IN}) + public ResponseEntity assignIssue( + @RequestHeader("Authorization") String token, + @PathVariable String issueKey, + @RequestBody AssigneeRequest assigneeRequest, + HttpServletRequest request, + HttpServletResponse response + ) throws JsonProcessingException, HttpException { + + Span span = tracer.spanBuilder("jira-proxy-assign-issue").startSpan(); + try (Scope scope = span.makeCurrent()) { + String compactJwt = token.startsWith("Bearer ") ? token.substring(7) : token; + + if (!keycloakService.validateJwt(compactJwt)) { + log.warn("Invalid Keycloak token"); + return ResponseEntity.status(HttpStatus.SC_UNAUTHORIZED).body("Invalid Keycloak token"); + } + + var operatingUser = getOperatingUser(request, response); + if (null == operatingUser) { + return ResponseEntity.status(HttpStatus.SC_UNAUTHORIZED).body("User not authenticated"); + } + + List jiraIntegrations = integrationSecurityTokenService + .findByConnectionType("jira"); + + if (jiraIntegrations.isEmpty()) { + return ResponseEntity.status(HttpStatus.SC_NOT_FOUND).body("No JIRA integration configured"); + } + + IntegrationSecurityToken jiraIntegration = jiraIntegrations.get(0); + JiraService jiraService = new JiraService(new RestTemplate(), jiraIntegration); + + Optional assigneeId = Optional.ofNullable(assigneeRequest.getAccountId()); + boolean success = jiraService.assignTicket(issueKey, assigneeId); + + span.setAttribute("issue.key", issueKey); + span.setAttribute("assignee.id", assigneeId.orElse("unassigned")); + span.setAttribute("assignment.success", success); + + if (success) { + return ResponseEntity.noContent().build(); + } else { + return ResponseEntity.status(HttpStatus.SC_INTERNAL_SERVER_ERROR) + .body("Failed to assign issue"); + } + + } finally { + span.end(); + } + } + + private String extractCommentText(CommentRequest commentRequest) { + // Handle both simple text and JIRA's complex body structure + if (commentRequest.getBody() != null) { + // Try to extract text from JIRA's structured body format + Object body = commentRequest.getBody(); + if (body instanceof String) { + return (String) body; + } + // For complex body structures, try to extract text + // This would need more sophisticated parsing for real JIRA body format + return body.toString(); + } + return commentRequest.getText(); + } + + // DTOs for request/response + public static class CommentRequest { + private Object body; + private String text; + + public Object getBody() { return body; } + public void setBody(Object body) { this.body = body; } + public String getText() { return text; } + public void setText(String text) { this.text = text; } + } + + public static class AssigneeRequest { + private String accountId; + + public String getAccountId() { return accountId; } + public void setAccountId(String accountId) { this.accountId = accountId; } + } + + public static class IssueStatusResponse { + private final String key; + private final String status; + + public IssueStatusResponse(String key, String status) { + this.key = key; + this.status = status; + } + + public String getKey() { return key; } + public String getStatus() { return status; } + } + + public static class CommentResponse { + private final String message; + + public CommentResponse(String message) { + this.message = message; + } + + public String getMessage() { return message; } + } +} \ No newline at end of file diff --git a/llm-proxy/src/main/java/io/sentrius/sso/controllers/api/OpenAIProxyController.java b/integration-proxy/src/main/java/io/sentrius/sso/controllers/api/OpenAIProxyController.java similarity index 100% rename from llm-proxy/src/main/java/io/sentrius/sso/controllers/api/OpenAIProxyController.java rename to integration-proxy/src/main/java/io/sentrius/sso/controllers/api/OpenAIProxyController.java diff --git a/llm-proxy/src/main/resources/application.properties b/integration-proxy/src/main/resources/application.properties similarity index 98% rename from llm-proxy/src/main/resources/application.properties rename to integration-proxy/src/main/resources/application.properties index efa86912..1a62e904 100644 --- a/llm-proxy/src/main/resources/application.properties +++ b/integration-proxy/src/main/resources/application.properties @@ -87,7 +87,7 @@ otel.traces.exporter=otlp otel.exporter.otlp.protocol=grpc otel.metrics.exporter=none otel.logs.exporter=none -otel.resource.attributes.service.name=llm-proxy +otel.resource.attributes.service.name=integration-proxy otel.traces.sampler=always_on otel.exporter.otlp.timeout=10s diff --git a/llm-proxy/src/main/resources/java-agents.yaml b/integration-proxy/src/main/resources/java-agents.yaml similarity index 100% rename from llm-proxy/src/main/resources/java-agents.yaml rename to integration-proxy/src/main/resources/java-agents.yaml diff --git a/llm-proxy/src/main/resources/sentriussql b/integration-proxy/src/main/resources/sentriussql similarity index 100% rename from llm-proxy/src/main/resources/sentriussql rename to integration-proxy/src/main/resources/sentriussql diff --git a/llm-proxy/src/test/java/io/sentrius/sso/config/AsyncConfigTest.java b/integration-proxy/src/test/java/io/sentrius/sso/config/AsyncConfigTest.java similarity index 100% rename from llm-proxy/src/test/java/io/sentrius/sso/config/AsyncConfigTest.java rename to integration-proxy/src/test/java/io/sentrius/sso/config/AsyncConfigTest.java diff --git a/integration-proxy/src/test/java/io/sentrius/sso/controllers/api/JiraProxyControllerTest.java b/integration-proxy/src/test/java/io/sentrius/sso/controllers/api/JiraProxyControllerTest.java new file mode 100644 index 00000000..d0a2a829 --- /dev/null +++ b/integration-proxy/src/test/java/io/sentrius/sso/controllers/api/JiraProxyControllerTest.java @@ -0,0 +1,244 @@ +package io.sentrius.sso.controllers.api; + +import io.sentrius.sso.config.ApplicationConfig; +import io.sentrius.sso.core.config.SystemOptions; +import io.sentrius.sso.core.dto.TicketDTO; +import io.sentrius.sso.core.integrations.external.ExternalIntegrationDTO; +import io.sentrius.sso.core.model.security.IntegrationSecurityToken; +import io.sentrius.sso.core.model.users.User; +import io.sentrius.sso.core.services.ErrorOutputService; +import io.sentrius.sso.core.services.UserService; +import io.sentrius.sso.core.services.security.IntegrationSecurityTokenService; +import io.sentrius.sso.core.services.security.KeycloakService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class JiraProxyControllerTest { + + @Mock + private UserService userService; + + @Mock + private SystemOptions systemOptions; + + @Mock + private ErrorOutputService errorOutputService; + + @Mock + private KeycloakService keycloakService; + + @Mock + private IntegrationSecurityTokenService integrationSecurityTokenService; + + @Mock + private RestTemplateBuilder restTemplateBuilder; + + @Mock + private ApplicationConfig applicationConfig; + + @Mock + private User mockUser; + + private JiraProxyController jiraProxyController; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + void setUp() { + jiraProxyController = spy(new JiraProxyController( + userService, systemOptions, errorOutputService, + keycloakService, integrationSecurityTokenService, + restTemplateBuilder, applicationConfig + )); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Test + void searchReturnsUnauthorizedWhenTokenIsInvalid() throws Exception { + // Given + String invalidToken = "Bearer invalid-token"; + when(keycloakService.validateJwt("invalid-token")).thenReturn(false); + + // When + ResponseEntity result = jiraProxyController.search( + invalidToken, "test query", null, request, response + ); + + // Then + assertEquals(HttpStatus.UNAUTHORIZED.value(), result.getStatusCode().value()); + assertEquals("Invalid Keycloak token", result.getBody()); + } + + @Test + void searchReturnsNotFoundWhenNoJiraIntegrationConfigured() throws Exception { + // Given + String validToken = "Bearer valid-token"; + when(keycloakService.validateJwt("valid-token")).thenReturn(true); + doReturn(mockUser).when(jiraProxyController).getOperatingUser(any(), any()); + when(integrationSecurityTokenService.findByConnectionType("jira")) + .thenReturn(Collections.emptyList()); + + // When + ResponseEntity result = jiraProxyController.search( + validToken, "test query", null, request, response + ); + + // Then + assertEquals(HttpStatus.NOT_FOUND.value(), result.getStatusCode().value()); + assertEquals("No JIRA integration configured", result.getBody()); + } + + @Test + void searchReturnsBadRequestWhenNoQueryProvided() throws Exception { + // Given + String validToken = "Bearer valid-token"; + when(keycloakService.validateJwt("valid-token")).thenReturn(true); + doReturn(mockUser).when(jiraProxyController).getOperatingUser(any(), any()); + + IntegrationSecurityToken mockToken = mock(IntegrationSecurityToken.class); + when(mockToken.getConnectionInfo()).thenReturn( + "{\"baseUrl\":\"https://test.atlassian.net\",\"apiToken\":\"token\",\"username\":\"user\"}" + ); + when(integrationSecurityTokenService.findByConnectionType("jira")) + .thenReturn(Arrays.asList(mockToken)); + + // When + ResponseEntity result = jiraProxyController.search( + validToken, null, null, request, response + ); + + // Then + assertEquals(HttpStatus.BAD_REQUEST.value(), result.getStatusCode().value()); + assertEquals("Either 'jql' or 'query' parameter is required", result.getBody()); + } + + @Test + void getIssueReturnsUnauthorizedWhenTokenIsInvalid() throws Exception { + // Given + String invalidToken = "Bearer invalid-token"; + when(keycloakService.validateJwt("invalid-token")).thenReturn(false); + + // When + ResponseEntity result = jiraProxyController.getIssue( + invalidToken, "TEST-123", request, response + ); + + // Then + assertEquals(HttpStatus.UNAUTHORIZED.value(), result.getStatusCode().value()); + assertEquals("Invalid Keycloak token", result.getBody()); + } + + @Test + void getIssueReturnsNotFoundWhenNoJiraIntegrationConfigured() throws Exception { + // Given + String validToken = "Bearer valid-token"; + when(keycloakService.validateJwt("valid-token")).thenReturn(true); + doReturn(mockUser).when(jiraProxyController).getOperatingUser(any(), any()); + when(integrationSecurityTokenService.findByConnectionType("jira")) + .thenReturn(Collections.emptyList()); + + // When + ResponseEntity result = jiraProxyController.getIssue( + validToken, "TEST-123", request, response + ); + + // Then + assertEquals(HttpStatus.NOT_FOUND.value(), result.getStatusCode().value()); + assertEquals("No JIRA integration configured", result.getBody()); + } + + @Test + void addCommentReturnsUnauthorizedWhenTokenIsInvalid() throws Exception { + // Given + String invalidToken = "Bearer invalid-token"; + when(keycloakService.validateJwt("invalid-token")).thenReturn(false); + + JiraProxyController.CommentRequest commentRequest = new JiraProxyController.CommentRequest(); + commentRequest.setText("Test comment"); + + // When + ResponseEntity result = jiraProxyController.addComment( + invalidToken, "TEST-123", commentRequest, request, response + ); + + // Then + assertEquals(HttpStatus.UNAUTHORIZED.value(), result.getStatusCode().value()); + assertEquals("Invalid Keycloak token", result.getBody()); + } + + @Test + void assignIssueReturnsUnauthorizedWhenTokenIsInvalid() throws Exception { + // Given + String invalidToken = "Bearer invalid-token"; + when(keycloakService.validateJwt("invalid-token")).thenReturn(false); + + JiraProxyController.AssigneeRequest assigneeRequest = new JiraProxyController.AssigneeRequest(); + assigneeRequest.setAccountId("test-account-id"); + + // When + ResponseEntity result = jiraProxyController.assignIssue( + invalidToken, "TEST-123", assigneeRequest, request, response + ); + + // Then + assertEquals(HttpStatus.UNAUTHORIZED.value(), result.getStatusCode().value()); + assertEquals("Invalid Keycloak token", result.getBody()); + } + + @Test + void extractCommentTextHandlesSimpleText() { + // Given + JiraProxyController.CommentRequest commentRequest = new JiraProxyController.CommentRequest(); + commentRequest.setText("Simple text comment"); + + // When + String result = invokePrivateExtractCommentText(commentRequest); + + // Then + assertEquals("Simple text comment", result); + } + + @Test + void extractCommentTextHandlesBodyAsString() { + // Given + JiraProxyController.CommentRequest commentRequest = new JiraProxyController.CommentRequest(); + commentRequest.setBody("Body as string"); + + // When + String result = invokePrivateExtractCommentText(commentRequest); + + // Then + assertEquals("Body as string", result); + } + + // Helper method to access private method for testing + private String invokePrivateExtractCommentText(JiraProxyController.CommentRequest commentRequest) { + try { + java.lang.reflect.Method method = JiraProxyController.class.getDeclaredMethod( + "extractCommentText", JiraProxyController.CommentRequest.class); + method.setAccessible(true); + return (String) method.invoke(jiraProxyController, commentRequest); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/llm-proxy/src/test/resources/configs/application.properties b/integration-proxy/src/test/resources/configs/application.properties similarity index 100% rename from llm-proxy/src/test/resources/configs/application.properties rename to integration-proxy/src/test/resources/configs/application.properties diff --git a/llm-proxy/src/test/resources/configs/exampleInstall.yml b/integration-proxy/src/test/resources/configs/exampleInstall.yml similarity index 100% rename from llm-proxy/src/test/resources/configs/exampleInstall.yml rename to integration-proxy/src/test/resources/configs/exampleInstall.yml diff --git a/llm-proxy/src/test/resources/configs/exampleInstallWithTypes.yml b/integration-proxy/src/test/resources/configs/exampleInstallWithTypes.yml similarity index 100% rename from llm-proxy/src/test/resources/configs/exampleInstallWithTypes.yml rename to integration-proxy/src/test/resources/configs/exampleInstallWithTypes.yml diff --git a/llm-proxy/src/test/resources/configs/exampleWrongInstall.yml b/integration-proxy/src/test/resources/configs/exampleWrongInstall.yml similarity index 100% rename from llm-proxy/src/test/resources/configs/exampleWrongInstall.yml rename to integration-proxy/src/test/resources/configs/exampleWrongInstall.yml diff --git a/llm-proxy/src/test/resources/configs/priv_key b/integration-proxy/src/test/resources/configs/priv_key similarity index 100% rename from llm-proxy/src/test/resources/configs/priv_key rename to integration-proxy/src/test/resources/configs/priv_key diff --git a/llm-proxy/src/test/resources/configs/priv_key.pub b/integration-proxy/src/test/resources/configs/priv_key.pub similarity index 100% rename from llm-proxy/src/test/resources/configs/priv_key.pub rename to integration-proxy/src/test/resources/configs/priv_key.pub diff --git a/llm-proxy/sso.jceks b/integration-proxy/sso.jceks similarity index 100% rename from llm-proxy/sso.jceks rename to integration-proxy/sso.jceks diff --git a/integration-proxy/test-jira-proxy.sh b/integration-proxy/test-jira-proxy.sh new file mode 100755 index 00000000..63c27107 --- /dev/null +++ b/integration-proxy/test-jira-proxy.sh @@ -0,0 +1,61 @@ +#!/bin/bash + +# JIRA Proxy API Test Script +# This script demonstrates how to interact with the JIRA proxy endpoints + +# Configuration +BASE_URL="http://localhost:8080" +JWT_TOKEN="your-jwt-token-here" + +echo "Testing JIRA Proxy API Endpoints" +echo "=================================" + +# Test 1: Search for issues +echo -e "\n1. Testing search endpoint..." +curl -X GET \ + "${BASE_URL}/api/v1/jira/rest/api/3/search?query=test" \ + -H "Authorization: Bearer ${JWT_TOKEN}" \ + -H "Content-Type: application/json" \ + --silent --show-error || echo "Search test failed (expected if no auth)" + +# Test 2: Get specific issue +echo -e "\n2. Testing get issue endpoint..." +curl -X GET \ + "${BASE_URL}/api/v1/jira/rest/api/3/issue/TEST-123" \ + -H "Authorization: Bearer ${JWT_TOKEN}" \ + -H "Content-Type: application/json" \ + --silent --show-error || echo "Get issue test failed (expected if no auth)" + +# Test 3: Add comment +echo -e "\n3. Testing add comment endpoint..." +curl -X POST \ + "${BASE_URL}/api/v1/jira/rest/api/3/issue/TEST-123/comment" \ + -H "Authorization: Bearer ${JWT_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"text": "Test comment from compliance agent"}' \ + --silent --show-error || echo "Add comment test failed (expected if no auth)" + +# Test 4: Assign issue +echo -e "\n4. Testing assign issue endpoint..." +curl -X PUT \ + "${BASE_URL}/api/v1/jira/rest/api/3/issue/TEST-123/assignee" \ + -H "Authorization: Bearer ${JWT_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"accountId": "test-user-id"}' \ + --silent --show-error || echo "Assign issue test failed (expected if no auth)" + +echo -e "\n\nAPI endpoint testing completed." +echo "Note: These tests will fail with authentication errors unless you have:" +echo "1. A running instance of the integration-proxy service" +echo "2. A valid JWT token" +echo "3. A configured JIRA integration" + +# Test with invalid token to verify security +echo -e "\nTesting security with invalid token..." +curl -X GET \ + "${BASE_URL}/api/v1/jira/rest/api/3/search?query=test" \ + -H "Authorization: Bearer invalid-token" \ + -H "Content-Type: application/json" \ + --include --silent --show-error | head -1 + +echo -e "\nExpected: HTTP 401 Unauthorized for invalid token" \ No newline at end of file diff --git a/pom.xml b/pom.xml index 1dc53d25..ad1a3c1c 100644 --- a/pom.xml +++ b/pom.xml @@ -14,7 +14,7 @@ llm-dataplane provenance-ingestor api - llm-proxy + integration-proxy analytics ai-agent agent-launcher diff --git a/provenance-ingestor/src/main/resources/application.properties b/provenance-ingestor/src/main/resources/application.properties index 794e4ebd..3f4c0e6c 100644 --- a/provenance-ingestor/src/main/resources/application.properties +++ b/provenance-ingestor/src/main/resources/application.properties @@ -75,7 +75,7 @@ otel.traces.exporter=otlp otel.exporter.otlp.protocol=grpc otel.metrics.exporter=none otel.logs.exporter=none -otel.resource.attributes.service.name=llm-proxy +otel.resource.attributes.service.name=integration-proxy otel.traces.sampler=always_on otel.exporter.otlp.timeout=10s diff --git a/sentrius-chart/templates/configmap.yaml b/sentrius-chart/templates/configmap.yaml index b1ba18f4..2919b393 100644 --- a/sentrius-chart/templates/configmap.yaml +++ b/sentrius-chart/templates/configmap.yaml @@ -54,7 +54,7 @@ data: otel.traces.exporter=otlp otel.metrics.exporter=none otel.exporter.otlp.endpoint=http://sentrius-jaeger:4317 - otel.resource.attributes.service.name=llm-proxy + otel.resource.attributes.service.name=integration-proxy otel.traces.sampler=always_on otel.exporter.otlp.timeout=10s otel.exporter.otlp.protocol=grpc diff --git a/sentrius-chart/templates/llmproxy-deployment.yaml b/sentrius-chart/templates/integrationproxy-deployment.yaml similarity index 88% rename from sentrius-chart/templates/llmproxy-deployment.yaml rename to sentrius-chart/templates/integrationproxy-deployment.yaml index b204a529..b1e553a1 100644 --- a/sentrius-chart/templates/llmproxy-deployment.yaml +++ b/sentrius-chart/templates/integrationproxy-deployment.yaml @@ -1,18 +1,18 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: {{ .Release.Name }}-llmproxy + name: {{ .Release.Name }}-integrationproxy labels: {{- include "sentrius.labels" . | nindent 4 }} spec: replicas: {{ .Values.replicaCount }} selector: matchLabels: - app: llmproxy + app: integrationproxy template: metadata: labels: - app: llmproxy + app: integrationproxy spec: initContainers: - name: wait-for-postgres @@ -20,8 +20,8 @@ spec: command: [ 'sh', '-c', 'until nc -z {{ .Release.Name }}-sentrius 8080; do echo waiting for postgres; sleep 2; done;' ] containers: - - name: llmproxy - image: "{{ .Values.llmproxy.image.repository }}:{{ .Values.llmproxy.image.tag }}" + - name: integrationproxy + image: "{{ .Values.integrationproxy.image.repository }}:{{ .Values.integrationproxy.image.tag }}" imagePullPolicy: {{ .Values.sentrius.image.pullPolicy }} ports: - containerPort: {{ .Values.sentrius.port }} diff --git a/sentrius-chart/templates/llmproxy-service.yaml b/sentrius-chart/templates/integrationproxy-service.yaml similarity index 83% rename from sentrius-chart/templates/llmproxy-service.yaml rename to sentrius-chart/templates/integrationproxy-service.yaml index 82bd24a6..b1a913dc 100644 --- a/sentrius-chart/templates/llmproxy-service.yaml +++ b/sentrius-chart/templates/integrationproxy-service.yaml @@ -1,7 +1,7 @@ apiVersion: v1 kind: Service metadata: - name: {{ .Release.Name }}-llmproxy + name: {{ .Release.Name }}-integrationproxy namespace: {{ .Values.tenant }} annotations: {{- if eq .Values.environment "gke" }} @@ -16,7 +16,7 @@ metadata: {{- end }} {{- end }} labels: - app: llmproxy + app: integrationproxy spec: type: {{ .Values.sentrius.serviceType }} ports: @@ -24,7 +24,7 @@ spec: port: {{ .Values.sentrius.port }} targetPort: {{ .Values.sentrius.port }} # Port used inside the container {{- if eq .Values.sentrius.serviceType "NodePort" }} - nodePort: {{ .Values.llmproxy.nodePort | default 30080 }} + nodePort: {{ .Values.integrationproxy.nodePort | default 30080 }} {{- end }} selector: - app: sentrius \ No newline at end of file + app: integrationproxy \ No newline at end of file diff --git a/sentrius-chart/values.yaml b/sentrius-chart/values.yaml index 028a3388..f41e1db1 100644 --- a/sentrius-chart/values.yaml +++ b/sentrius-chart/values.yaml @@ -45,9 +45,9 @@ sentrius: service.beta.kubernetes.io/azure-load-balancer-internal: "true" # Sentrius configuration -llmproxy: +integrationproxy: image: - repository: us-central1-docker.pkg.dev/sentrius-project/llmproxy-repo + repository: us-central1-docker.pkg.dev/sentrius-project/integration-proxy-repo tag: tag pullPolicy: IfNotPresent port: 8080