1+ package io .sentrius .sso .controllers .api ;
2+
3+ import java .util .List ;
4+ import java .util .Optional ;
5+ import java .util .concurrent .ExecutionException ;
6+ import com .fasterxml .jackson .core .JsonProcessingException ;
7+ import io .opentelemetry .api .GlobalOpenTelemetry ;
8+ import io .opentelemetry .api .trace .Span ;
9+ import io .opentelemetry .api .trace .Tracer ;
10+ import io .opentelemetry .context .Scope ;
11+ import io .sentrius .sso .config .ApplicationConfig ;
12+ import io .sentrius .sso .core .annotations .LimitAccess ;
13+ import io .sentrius .sso .core .config .SystemOptions ;
14+ import io .sentrius .sso .core .controllers .BaseController ;
15+ import io .sentrius .sso .core .dto .TicketDTO ;
16+ import io .sentrius .sso .core .integrations .ticketing .JiraService ;
17+ import io .sentrius .sso .core .model .security .IntegrationSecurityToken ;
18+ import io .sentrius .sso .core .model .security .enums .ApplicationAccessEnum ;
19+ import io .sentrius .sso .core .services .ErrorOutputService ;
20+ import io .sentrius .sso .core .services .UserService ;
21+ import io .sentrius .sso .core .services .security .IntegrationSecurityTokenService ;
22+ import io .sentrius .sso .core .services .security .KeycloakService ;
23+ import io .sentrius .sso .integrations .exceptions .HttpException ;
24+ import jakarta .servlet .http .HttpServletRequest ;
25+ import jakarta .servlet .http .HttpServletResponse ;
26+ import lombok .extern .slf4j .Slf4j ;
27+ import org .apache .http .HttpStatus ;
28+ import org .springframework .boot .web .client .RestTemplateBuilder ;
29+ import org .springframework .http .ResponseEntity ;
30+ import org .springframework .web .bind .annotation .*;
31+ import org .springframework .web .client .RestTemplate ;
32+
33+ @ RestController
34+ @ RequestMapping ("/api/v1/jira" )
35+ @ Slf4j
36+ public class JiraProxyController extends BaseController {
37+
38+ final KeycloakService keycloakService ;
39+ final IntegrationSecurityTokenService integrationSecurityTokenService ;
40+ final RestTemplateBuilder restTemplateBuilder ;
41+ final ApplicationConfig applicationConfig ;
42+
43+ Tracer tracer = GlobalOpenTelemetry .getTracer ("io.sentrius.sso" );
44+
45+ protected JiraProxyController (
46+ UserService userService ,
47+ SystemOptions systemOptions ,
48+ ErrorOutputService errorOutputService ,
49+ KeycloakService keycloakService ,
50+ IntegrationSecurityTokenService integrationSecurityTokenService ,
51+ RestTemplateBuilder restTemplateBuilder ,
52+ ApplicationConfig applicationConfig
53+ ) {
54+ super (userService , systemOptions , errorOutputService );
55+ this .keycloakService = keycloakService ;
56+ this .integrationSecurityTokenService = integrationSecurityTokenService ;
57+ this .restTemplateBuilder = restTemplateBuilder ;
58+ this .applicationConfig = applicationConfig ;
59+ }
60+
61+ @ GetMapping ("/rest/api/3/search" )
62+ @ LimitAccess (applicationAccess = {ApplicationAccessEnum .CAN_LOG_IN })
63+ public ResponseEntity <?> search (
64+ @ RequestHeader ("Authorization" ) String token ,
65+ @ RequestParam (value = "jql" , required = false ) String jql ,
66+ @ RequestParam (value = "query" , required = false ) String query ,
67+ HttpServletRequest request ,
68+ HttpServletResponse response
69+ ) throws JsonProcessingException , HttpException {
70+
71+ Span span = tracer .spanBuilder ("jira-proxy-search" ).startSpan ();
72+ try (Scope scope = span .makeCurrent ()) {
73+ String compactJwt = token .startsWith ("Bearer " ) ? token .substring (7 ) : token ;
74+
75+ if (!keycloakService .validateJwt (compactJwt )) {
76+ log .warn ("Invalid Keycloak token" );
77+ return ResponseEntity .status (HttpStatus .SC_UNAUTHORIZED ).body ("Invalid Keycloak token" );
78+ }
79+
80+ var operatingUser = getOperatingUser (request , response );
81+ if (null == operatingUser ) {
82+ return ResponseEntity .status (HttpStatus .SC_UNAUTHORIZED ).body ("User not authenticated" );
83+ }
84+
85+ // Get the first available JIRA integration for the user
86+ // In a production environment, you might want to allow specifying which integration to use
87+ List <IntegrationSecurityToken > jiraIntegrations = integrationSecurityTokenService
88+ .findByConnectionType ("jira" );
89+
90+ if (jiraIntegrations .isEmpty ()) {
91+ return ResponseEntity .status (HttpStatus .SC_NOT_FOUND ).body ("No JIRA integration configured" );
92+ }
93+
94+ IntegrationSecurityToken jiraIntegration = jiraIntegrations .get (0 );
95+ JiraService jiraService = new JiraService (new RestTemplate (), jiraIntegration );
96+
97+ // Use the query parameter if jql is not provided
98+ String searchQuery = jql != null ? jql : query ;
99+ if (searchQuery == null ) {
100+ return ResponseEntity .badRequest ().body ("Either 'jql' or 'query' parameter is required" );
101+ }
102+
103+ List <TicketDTO > tickets = jiraService .searchForIncidents (searchQuery );
104+
105+ span .setAttribute ("search.query" , searchQuery );
106+ span .setAttribute ("search.results.count" , tickets .size ());
107+
108+ return ResponseEntity .ok (tickets );
109+
110+ } catch (ExecutionException | InterruptedException e ) {
111+ log .error ("Error executing JIRA search" , e );
112+ throw new RuntimeException (e );
113+ } finally {
114+ span .end ();
115+ }
116+ }
117+
118+ @ GetMapping ("/rest/api/3/issue/{issueKey}" )
119+ @ LimitAccess (applicationAccess = {ApplicationAccessEnum .CAN_LOG_IN })
120+ public ResponseEntity <?> getIssue (
121+ @ RequestHeader ("Authorization" ) String token ,
122+ @ PathVariable String issueKey ,
123+ HttpServletRequest request ,
124+ HttpServletResponse response
125+ ) throws JsonProcessingException , HttpException {
126+
127+ Span span = tracer .spanBuilder ("jira-proxy-get-issue" ).startSpan ();
128+ try (Scope scope = span .makeCurrent ()) {
129+ String compactJwt = token .startsWith ("Bearer " ) ? token .substring (7 ) : token ;
130+
131+ if (!keycloakService .validateJwt (compactJwt )) {
132+ log .warn ("Invalid Keycloak token" );
133+ return ResponseEntity .status (HttpStatus .SC_UNAUTHORIZED ).body ("Invalid Keycloak token" );
134+ }
135+
136+ var operatingUser = getOperatingUser (request , response );
137+ if (null == operatingUser ) {
138+ return ResponseEntity .status (HttpStatus .SC_UNAUTHORIZED ).body ("User not authenticated" );
139+ }
140+
141+ List <IntegrationSecurityToken > jiraIntegrations = integrationSecurityTokenService
142+ .findByConnectionType ("jira" );
143+
144+ if (jiraIntegrations .isEmpty ()) {
145+ return ResponseEntity .status (HttpStatus .SC_NOT_FOUND ).body ("No JIRA integration configured" );
146+ }
147+
148+ IntegrationSecurityToken jiraIntegration = jiraIntegrations .get (0 );
149+ JiraService jiraService = new JiraService (new RestTemplate (), jiraIntegration );
150+
151+ boolean isActive = jiraService .isTicketActive (issueKey );
152+
153+ span .setAttribute ("issue.key" , issueKey );
154+ span .setAttribute ("issue.active" , isActive );
155+
156+ return ResponseEntity .ok (new IssueStatusResponse (issueKey , isActive ? "Active" : "Inactive" ));
157+
158+ } finally {
159+ span .end ();
160+ }
161+ }
162+
163+ @ PostMapping ("/rest/api/3/issue/{issueKey}/comment" )
164+ @ LimitAccess (applicationAccess = {ApplicationAccessEnum .CAN_LOG_IN })
165+ public ResponseEntity <?> addComment (
166+ @ RequestHeader ("Authorization" ) String token ,
167+ @ PathVariable String issueKey ,
168+ @ RequestBody CommentRequest commentRequest ,
169+ HttpServletRequest request ,
170+ HttpServletResponse response
171+ ) throws JsonProcessingException , HttpException {
172+
173+ Span span = tracer .spanBuilder ("jira-proxy-add-comment" ).startSpan ();
174+ try (Scope scope = span .makeCurrent ()) {
175+ String compactJwt = token .startsWith ("Bearer " ) ? token .substring (7 ) : token ;
176+
177+ if (!keycloakService .validateJwt (compactJwt )) {
178+ log .warn ("Invalid Keycloak token" );
179+ return ResponseEntity .status (HttpStatus .SC_UNAUTHORIZED ).body ("Invalid Keycloak token" );
180+ }
181+
182+ var operatingUser = getOperatingUser (request , response );
183+ if (null == operatingUser ) {
184+ return ResponseEntity .status (HttpStatus .SC_UNAUTHORIZED ).body ("User not authenticated" );
185+ }
186+
187+ List <IntegrationSecurityToken > jiraIntegrations = integrationSecurityTokenService
188+ .findByConnectionType ("jira" );
189+
190+ if (jiraIntegrations .isEmpty ()) {
191+ return ResponseEntity .status (HttpStatus .SC_NOT_FOUND ).body ("No JIRA integration configured" );
192+ }
193+
194+ IntegrationSecurityToken jiraIntegration = jiraIntegrations .get (0 );
195+ JiraService jiraService = new JiraService (new RestTemplate (), jiraIntegration );
196+
197+ // Extract comment text from the request
198+ String commentText = extractCommentText (commentRequest );
199+ if (commentText == null || commentText .trim ().isEmpty ()) {
200+ return ResponseEntity .badRequest ().body ("Comment text is required" );
201+ }
202+
203+ boolean success = jiraService .updateTicket (issueKey , commentText );
204+
205+ span .setAttribute ("issue.key" , issueKey );
206+ span .setAttribute ("comment.success" , success );
207+
208+ if (success ) {
209+ return ResponseEntity .ok (new CommentResponse ("Comment added successfully" ));
210+ } else {
211+ return ResponseEntity .status (HttpStatus .SC_INTERNAL_SERVER_ERROR )
212+ .body ("Failed to add comment to issue" );
213+ }
214+
215+ } finally {
216+ span .end ();
217+ }
218+ }
219+
220+ @ PutMapping ("/rest/api/3/issue/{issueKey}/assignee" )
221+ @ LimitAccess (applicationAccess = {ApplicationAccessEnum .CAN_LOG_IN })
222+ public ResponseEntity <?> assignIssue (
223+ @ RequestHeader ("Authorization" ) String token ,
224+ @ PathVariable String issueKey ,
225+ @ RequestBody AssigneeRequest assigneeRequest ,
226+ HttpServletRequest request ,
227+ HttpServletResponse response
228+ ) throws JsonProcessingException , HttpException {
229+
230+ Span span = tracer .spanBuilder ("jira-proxy-assign-issue" ).startSpan ();
231+ try (Scope scope = span .makeCurrent ()) {
232+ String compactJwt = token .startsWith ("Bearer " ) ? token .substring (7 ) : token ;
233+
234+ if (!keycloakService .validateJwt (compactJwt )) {
235+ log .warn ("Invalid Keycloak token" );
236+ return ResponseEntity .status (HttpStatus .SC_UNAUTHORIZED ).body ("Invalid Keycloak token" );
237+ }
238+
239+ var operatingUser = getOperatingUser (request , response );
240+ if (null == operatingUser ) {
241+ return ResponseEntity .status (HttpStatus .SC_UNAUTHORIZED ).body ("User not authenticated" );
242+ }
243+
244+ List <IntegrationSecurityToken > jiraIntegrations = integrationSecurityTokenService
245+ .findByConnectionType ("jira" );
246+
247+ if (jiraIntegrations .isEmpty ()) {
248+ return ResponseEntity .status (HttpStatus .SC_NOT_FOUND ).body ("No JIRA integration configured" );
249+ }
250+
251+ IntegrationSecurityToken jiraIntegration = jiraIntegrations .get (0 );
252+ JiraService jiraService = new JiraService (new RestTemplate (), jiraIntegration );
253+
254+ Optional <String > assigneeId = Optional .ofNullable (assigneeRequest .getAccountId ());
255+ boolean success = jiraService .assignTicket (issueKey , assigneeId );
256+
257+ span .setAttribute ("issue.key" , issueKey );
258+ span .setAttribute ("assignee.id" , assigneeId .orElse ("unassigned" ));
259+ span .setAttribute ("assignment.success" , success );
260+
261+ if (success ) {
262+ return ResponseEntity .noContent ().build ();
263+ } else {
264+ return ResponseEntity .status (HttpStatus .SC_INTERNAL_SERVER_ERROR )
265+ .body ("Failed to assign issue" );
266+ }
267+
268+ } finally {
269+ span .end ();
270+ }
271+ }
272+
273+ private String extractCommentText (CommentRequest commentRequest ) {
274+ // Handle both simple text and JIRA's complex body structure
275+ if (commentRequest .getBody () != null ) {
276+ // Try to extract text from JIRA's structured body format
277+ Object body = commentRequest .getBody ();
278+ if (body instanceof String ) {
279+ return (String ) body ;
280+ }
281+ // For complex body structures, try to extract text
282+ // This would need more sophisticated parsing for real JIRA body format
283+ return body .toString ();
284+ }
285+ return commentRequest .getText ();
286+ }
287+
288+ // DTOs for request/response
289+ public static class CommentRequest {
290+ private Object body ;
291+ private String text ;
292+
293+ public Object getBody () { return body ; }
294+ public void setBody (Object body ) { this .body = body ; }
295+ public String getText () { return text ; }
296+ public void setText (String text ) { this .text = text ; }
297+ }
298+
299+ public static class AssigneeRequest {
300+ private String accountId ;
301+
302+ public String getAccountId () { return accountId ; }
303+ public void setAccountId (String accountId ) { this .accountId = accountId ; }
304+ }
305+
306+ public static class IssueStatusResponse {
307+ private final String key ;
308+ private final String status ;
309+
310+ public IssueStatusResponse (String key , String status ) {
311+ this .key = key ;
312+ this .status = status ;
313+ }
314+
315+ public String getKey () { return key ; }
316+ public String getStatus () { return status ; }
317+ }
318+
319+ public static class CommentResponse {
320+ private final String message ;
321+
322+ public CommentResponse (String message ) {
323+ this .message = message ;
324+ }
325+
326+ public String getMessage () { return message ; }
327+ }
328+ }
0 commit comments