From 082d39044688fd59ea9ac3f1795f54e469be539e Mon Sep 17 00:00:00 2001 From: Damian Szafranek Date: Wed, 1 Apr 2026 17:07:00 +0200 Subject: [PATCH 1/2] MW-1449: Add Superset guest token endpoint for embedded dashboards --- .../report/domain/DashboardReport.java | 9 + .../report/dto/DashboardReportDto.java | 1 + .../report/service/SupersetService.java | 225 ++++++++++++++++ .../web/SupersetGuestTokenController.java | 56 ++++ src/main/resources/api-definition.yaml | 19 ++ src/main/resources/application.properties | 4 + ...add_embedded_uuid_to_dashboard_reports.sql | 1 + .../report/service/SupersetServiceTest.java | 255 ++++++++++++++++++ .../utils/DashboardReportDataBuilder.java | 2 + .../web/SupersetGuestTokenControllerTest.java | 80 ++++++ 10 files changed, 652 insertions(+) create mode 100644 src/main/java/org/openlmis/report/service/SupersetService.java create mode 100644 src/main/java/org/openlmis/report/web/SupersetGuestTokenController.java create mode 100644 src/main/resources/db/migration/20260401000000000__add_embedded_uuid_to_dashboard_reports.sql create mode 100644 src/test/java/org/openlmis/report/service/SupersetServiceTest.java create mode 100644 src/test/java/org/openlmis/report/web/SupersetGuestTokenControllerTest.java diff --git a/src/main/java/org/openlmis/report/domain/DashboardReport.java b/src/main/java/org/openlmis/report/domain/DashboardReport.java index 5779bb0..7d11df4 100644 --- a/src/main/java/org/openlmis/report/domain/DashboardReport.java +++ b/src/main/java/org/openlmis/report/domain/DashboardReport.java @@ -46,6 +46,9 @@ public class DashboardReport extends BaseEntity { @Column(columnDefinition = TEXT_COLUMN_DEFINITION, nullable = false) private String url; + @Column(columnDefinition = TEXT_COLUMN_DEFINITION) + private String embeddedUuid; + @Enumerated(EnumType.STRING) @Column(columnDefinition = TEXT_COLUMN_DEFINITION, nullable = false) private ReportType type; @@ -85,6 +88,7 @@ public static DashboardReport newInstance(DashboardReport.Importer importer) { public void updateFrom(DashboardReport.Importer importer) { this.name = importer.getName(); this.url = importer.getUrl(); + this.embeddedUuid = importer.getEmbeddedUuid(); this.type = importer.getType(); this.enabled = importer.isEnabled(); this.showOnHomePage = importer.isShowOnHomePage(); @@ -100,6 +104,7 @@ public void export(DashboardReport.Exporter exporter) { exporter.setId(id); exporter.setName(name); exporter.setUrl(url); + exporter.setEmbeddedUuid(embeddedUuid); exporter.setType(type); exporter.setEnabled(enabled); exporter.setShowOnHomePage(showOnHomePage); @@ -114,6 +119,8 @@ public interface Exporter { void setUrl(String url); + void setEmbeddedUuid(String embeddedUuid); + void setType(ReportType type); void setEnabled(boolean enabled); @@ -132,6 +139,8 @@ public interface Importer { String getUrl(); + String getEmbeddedUuid(); + ReportType getType(); boolean isEnabled(); diff --git a/src/main/java/org/openlmis/report/dto/DashboardReportDto.java b/src/main/java/org/openlmis/report/dto/DashboardReportDto.java index 9ad0626..93eafe1 100644 --- a/src/main/java/org/openlmis/report/dto/DashboardReportDto.java +++ b/src/main/java/org/openlmis/report/dto/DashboardReportDto.java @@ -39,6 +39,7 @@ public class DashboardReportDto implements Importer, Exporter { private UUID id; private String name; private String url; + private String embeddedUuid; private ReportType type; private boolean enabled; private boolean showOnHomePage; diff --git a/src/main/java/org/openlmis/report/service/SupersetService.java b/src/main/java/org/openlmis/report/service/SupersetService.java new file mode 100644 index 0000000..1f8927f --- /dev/null +++ b/src/main/java/org/openlmis/report/service/SupersetService.java @@ -0,0 +1,225 @@ +/* + * This program is part of the OpenLMIS logistics management information system platform software. + * Copyright © 2017 VillageReach + * + * This program is free software: you can redistribute it and/or modify it under the terms + * of the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. You should have received a copy of + * the GNU Affero General Public License along with this program. If not, see + * http://www.gnu.org/licenses.  For additional information contact info@OpenLMIS.org. + */ + +package org.openlmis.report.service; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +/** + * Service for communicating with the Superset API to obtain guest tokens + * for embedded dashboard access. + */ +@Service +public class SupersetService { + + private static final Logger LOGGER = LoggerFactory.getLogger(SupersetService.class); + + @Value("${superset.url:}") + private String supersetUrl; + + @Value("${superset.admin.user:admin}") + private String adminUser; + + @Value("${superset.admin.password:changeme}") + private String adminPassword; + + private final RestTemplate restTemplate = new RestTemplate(); + + private String cachedAccessToken; + private long tokenExpiresAt; + + /** + * Obtain a Superset guest token for the given embedded dashboard UUID. + * + * @param embeddedUuid the embedded UUID of the Superset dashboard + * @param username the username for the guest user + * @param firstName the first name for the guest user + * @param lastName the last name for the guest user + * @return the guest token string + */ + public String getGuestToken(String embeddedUuid, String username, + String firstName, String lastName) { + try { + return requestGuestToken(embeddedUuid, username, firstName, lastName); + } catch (HttpClientErrorException ex) { + if (ex.getStatusCode().value() == 401) { + LOGGER.info("Superset access token expired, re-authenticating"); + clearCachedToken(); + return requestGuestToken(embeddedUuid, username, firstName, lastName); + } + throw ex; + } + } + + private String requestGuestToken(String embeddedUuid, String username, + String firstName, String lastName) { + String accessToken = getAccessToken(); + + // Get CSRF token and session cookies + CsrfResult csrf = getCsrfToken(accessToken); + + // Build guest token request + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + accessToken); + headers.set("X-CSRFToken", csrf.token); + headers.set("Referer", supersetUrl + "/"); + if (csrf.cookies != null) { + headers.set("Cookie", csrf.cookies); + } + + Map user = new HashMap<>(); + user.put("username", username); + user.put("first_name", firstName); + user.put("last_name", lastName); + + Map resource = new HashMap<>(); + resource.put("type", "dashboard"); + resource.put("id", embeddedUuid); + + Map body = new HashMap<>(); + body.put("user", user); + body.put("resources", Collections.singletonList(resource)); + body.put("rls", Collections.emptyList()); + + HttpEntity> request = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.exchange( + supersetUrl + "/api/v1/security/guest_token/", + HttpMethod.POST, + request, + Map.class + ); + + Map responseBody = response.getBody(); + if (responseBody == null || !responseBody.containsKey("token")) { + throw new IllegalStateException("Unexpected Superset guest_token response: " + responseBody); + } + return (String) responseBody.get("token"); + } + + private synchronized String getAccessToken() { + if (cachedAccessToken != null && System.currentTimeMillis() < tokenExpiresAt) { + return cachedAccessToken; + } + return login(); + } + + private String login() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + Map body = new HashMap<>(); + body.put("username", adminUser); + body.put("password", adminPassword); + body.put("provider", "db"); + body.put("refresh", "true"); + + HttpEntity> request = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.exchange( + supersetUrl + "/api/v1/security/login", + HttpMethod.POST, + request, + Map.class + ); + + Map loginResponse = response.getBody(); + if (loginResponse == null || !loginResponse.containsKey("access_token")) { + throw new IllegalStateException("Unexpected Superset login response: " + loginResponse); + } + cachedAccessToken = (String) loginResponse.get("access_token"); + // Cache token for 4 minutes (Superset default expiry is 5 minutes) + tokenExpiresAt = System.currentTimeMillis() + (4 * 60 * 1000); + + LOGGER.debug("Successfully obtained Superset access token"); + return cachedAccessToken; + } + + private CsrfResult getCsrfToken(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + accessToken); + + HttpEntity request = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + supersetUrl + "/api/v1/security/csrf_token/", + HttpMethod.GET, + request, + Map.class + ); + + Map csrfBody = response.getBody(); + if (csrfBody == null || !csrfBody.containsKey("result")) { + throw new IllegalStateException("Unexpected Superset CSRF response: " + csrfBody); + } + String csrfToken = (String) csrfBody.get("result"); + + // Extract Set-Cookie headers to send back with the guest token request + List setCookies = response.getHeaders().get(HttpHeaders.SET_COOKIE); + String cookies = null; + if (setCookies != null && !setCookies.isEmpty()) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < setCookies.size(); i++) { + if (i > 0) { + sb.append("; "); + } + // Extract just the cookie name=value part (before any ;) + String cookie = setCookies.get(i); + int semicolonIdx = cookie.indexOf(';'); + if (semicolonIdx > 0) { + sb.append(cookie.substring(0, semicolonIdx)); + } else { + sb.append(cookie); + } + } + cookies = sb.toString(); + } + + return new CsrfResult(csrfToken, cookies); + } + + private synchronized void clearCachedToken() { + cachedAccessToken = null; + tokenExpiresAt = 0; + } + + /** + * Simple holder for CSRF token and associated session cookies. + */ + private static class CsrfResult { + final String token; + final String cookies; + + CsrfResult(String token, String cookies) { + this.token = token; + this.cookies = cookies; + } + } +} diff --git a/src/main/java/org/openlmis/report/web/SupersetGuestTokenController.java b/src/main/java/org/openlmis/report/web/SupersetGuestTokenController.java new file mode 100644 index 0000000..1ed54f5 --- /dev/null +++ b/src/main/java/org/openlmis/report/web/SupersetGuestTokenController.java @@ -0,0 +1,56 @@ +/* + * This program is part of the OpenLMIS logistics management information system platform software. + * Copyright © 2017 VillageReach + * + * This program is free software: you can redistribute it and/or modify it under the terms + * of the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. You should have received a copy of + * the GNU Affero General Public License along with this program. If not, see + * http://www.gnu.org/licenses.  For additional information contact info@OpenLMIS.org. + */ + +package org.openlmis.report.web; + +import java.util.Collections; +import java.util.Map; +import org.openlmis.report.service.SupersetService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; + +@Controller +@RequestMapping("/api/reports/superset") +public class SupersetGuestTokenController extends BaseController { + + @Autowired + private SupersetService supersetService; + + /** + * Get a Superset guest token for embedding a dashboard. + * + * @param embeddedUuid the embedded UUID of the Superset dashboard + * @return a map containing the guest token + */ + @GetMapping("/guest-token") + @ResponseStatus(HttpStatus.OK) + @ResponseBody + public Map getGuestToken(@RequestParam String embeddedUuid) { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + + String token = supersetService.getGuestToken( + embeddedUuid, username, username, username + ); + + return Collections.singletonMap("token", token); + } +} diff --git a/src/main/resources/api-definition.yaml b/src/main/resources/api-definition.yaml index b56aaeb..7fbf29b 100644 --- a/src/main/resources/api-definition.yaml +++ b/src/main/resources/api-definition.yaml @@ -423,6 +423,25 @@ resourceTypes: body: application/json: schema: localizedMessage + /superset: + /guest-token: + displayName: Superset guest token + get: + is: [ secured ] + description: Get a Superset guest token for embedding a dashboard. + queryParameters: + embeddedUuid: + displayName: embeddedUuid + type: string + required: true + responses: + 200: + body: + application/json: + 401: + body: + application/json: + schema: localizedMessage /dashboardReports: displayName: Dashboard reports get: diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 97cbeb8..d82b0d8 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -58,3 +58,7 @@ referencedata.url=${BASE_URL} notification.url=${BASE_URL} requisition.url=${BASE_URL} fulfillment.url=${BASE_URL} + +superset.url=${SUPERSET_URL:} +superset.admin.user=${SUPERSET_ADMIN_USER:admin} +superset.admin.password=${SUPERSET_ADMIN_PASSWORD:changeme} diff --git a/src/main/resources/db/migration/20260401000000000__add_embedded_uuid_to_dashboard_reports.sql b/src/main/resources/db/migration/20260401000000000__add_embedded_uuid_to_dashboard_reports.sql new file mode 100644 index 0000000..a22a3e9 --- /dev/null +++ b/src/main/resources/db/migration/20260401000000000__add_embedded_uuid_to_dashboard_reports.sql @@ -0,0 +1 @@ +ALTER TABLE report.dashboard_reports ADD COLUMN IF NOT EXISTS embeddeduuid text; diff --git a/src/test/java/org/openlmis/report/service/SupersetServiceTest.java b/src/test/java/org/openlmis/report/service/SupersetServiceTest.java new file mode 100644 index 0000000..11c2a72 --- /dev/null +++ b/src/test/java/org/openlmis/report/service/SupersetServiceTest.java @@ -0,0 +1,255 @@ +/* + * This program is part of the OpenLMIS logistics management information system platform software. + * Copyright © 2017 VillageReach + * + * This program is free software: you can redistribute it and/or modify it under the terms + * of the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. You should have received a copy of + * the GNU Affero General Public License along with this program. If not, see + * http://www.gnu.org/licenses.  For additional information contact info@OpenLMIS.org. + */ + +package org.openlmis.report.service; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +@RunWith(MockitoJUnitRunner.class) +public class SupersetServiceTest { + + private static final String SUPERSET_URL = "http://superset:8088"; + private static final String ADMIN_USER = "admin"; + private static final String ADMIN_PASSWORD = "secret"; + private static final String EMBEDDED_UUID = "test-dashboard-uuid"; + private static final String USERNAME = "testuser"; + private static final String ACCESS_TOKEN = "mock-access-token"; + private static final String CSRF_TOKEN = "mock-csrf-token"; + private static final String GUEST_TOKEN = "mock-guest-token"; + private static final String LOGIN_URL = + SUPERSET_URL + "/api/v1/security/login"; + private static final String CSRF_URL = + SUPERSET_URL + "/api/v1/security/csrf_token/"; + private static final String GUEST_TOKEN_URL = + SUPERSET_URL + "/api/v1/security/guest_token/"; + + @Mock + private RestTemplate restTemplate; + + private SupersetService supersetService; + + @Before + public void setUp() { + supersetService = new SupersetService(); + ReflectionTestUtils.setField(supersetService, "restTemplate", restTemplate); + ReflectionTestUtils.setField(supersetService, "supersetUrl", SUPERSET_URL); + ReflectionTestUtils.setField(supersetService, "adminUser", ADMIN_USER); + ReflectionTestUtils.setField(supersetService, "adminPassword", ADMIN_PASSWORD); + ReflectionTestUtils.setField(supersetService, "cachedAccessToken", null); + ReflectionTestUtils.setField(supersetService, "tokenExpiresAt", 0L); + } + + @Test + public void shouldLoginAndReturnGuestToken() { + // given + mockLoginResponse(); + mockCsrfResponse(null); + mockGuestTokenResponse(); + + // when + String token = supersetService.getGuestToken(EMBEDDED_UUID, USERNAME, USERNAME, USERNAME); + + // then + assertEquals(GUEST_TOKEN, token); + } + + @Test + public void shouldUseCachedTokenOnSecondCall() { + // given + mockLoginResponse(); + mockCsrfResponse(null); + mockGuestTokenResponse(); + + // when + supersetService.getGuestToken(EMBEDDED_UUID, USERNAME, USERNAME, USERNAME); + supersetService.getGuestToken(EMBEDDED_UUID, USERNAME, USERNAME, USERNAME); + + // then - login is called only once, but CSRF and guest_token are called twice each + verify(restTemplate, times(1)).exchange( + eq(LOGIN_URL), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(Map.class) + ); + verify(restTemplate, times(2)).exchange( + eq(CSRF_URL), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(Map.class) + ); + verify(restTemplate, times(2)).exchange( + eq(GUEST_TOKEN_URL), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(Map.class) + ); + } + + @Test + public void shouldRetryOnUnauthorized() { + // given + mockLoginResponse(); + mockCsrfResponse(null); + + // First guest_token call throws 401, subsequent calls succeed + Map guestTokenBody = new HashMap<>(); + guestTokenBody.put("token", GUEST_TOKEN); + + when(restTemplate.exchange( + eq(GUEST_TOKEN_URL), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(Map.class) + )) + .thenThrow(new HttpClientErrorException(HttpStatus.UNAUTHORIZED)) + .thenReturn(new ResponseEntity<>(guestTokenBody, HttpStatus.OK)); + + // when + String token = supersetService.getGuestToken(EMBEDDED_UUID, USERNAME, USERNAME, USERNAME); + + // then + assertEquals(GUEST_TOKEN, token); + // login should be called twice: once initially and once after 401 retry + verify(restTemplate, times(2)).exchange( + eq(LOGIN_URL), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(Map.class) + ); + } + + @Test(expected = IllegalStateException.class) + public void shouldThrowOnNullLoginResponse() { + // given + when(restTemplate.exchange( + eq(LOGIN_URL), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(Map.class) + )).thenReturn(new ResponseEntity<>(null, HttpStatus.OK)); + + // when + supersetService.getGuestToken(EMBEDDED_UUID, USERNAME, USERNAME, USERNAME); + } + + @Test(expected = IllegalStateException.class) + public void shouldThrowOnNullGuestTokenResponse() { + // given + mockLoginResponse(); + mockCsrfResponse(null); + + when(restTemplate.exchange( + eq(GUEST_TOKEN_URL), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(Map.class) + )).thenReturn(new ResponseEntity<>(null, HttpStatus.OK)); + + // when + supersetService.getGuestToken(EMBEDDED_UUID, USERNAME, USERNAME, USERNAME); + } + + @Test + @SuppressWarnings("unchecked") + public void shouldExtractCookiesFromCsrfResponse() { + // given + mockLoginResponse(); + mockCsrfResponse(Arrays.asList("session=abc123; Path=/; HttpOnly", "csrftoken=xyz; Path=/")); + mockGuestTokenResponse(); + + // when + supersetService.getGuestToken(EMBEDDED_UUID, USERNAME, USERNAME, USERNAME); + + // then - verify the guest token request includes cookies + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpEntity.class); + verify(restTemplate).exchange( + eq(GUEST_TOKEN_URL), + eq(HttpMethod.POST), + captor.capture(), + eq(Map.class) + ); + + HttpEntity capturedRequest = captor.getValue(); + String cookieHeader = capturedRequest.getHeaders().getFirst("Cookie"); + assertEquals("session=abc123; csrftoken=xyz", cookieHeader); + } + + private void mockLoginResponse() { + Map loginBody = new HashMap<>(); + loginBody.put("access_token", ACCESS_TOKEN); + + when(restTemplate.exchange( + eq(LOGIN_URL), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(Map.class) + )).thenReturn(new ResponseEntity<>(loginBody, HttpStatus.OK)); + } + + @SuppressWarnings("unchecked") + private void mockCsrfResponse(java.util.List setCookies) { + Map csrfBody = new HashMap<>(); + csrfBody.put("result", CSRF_TOKEN); + + HttpHeaders responseHeaders = new HttpHeaders(); + if (setCookies != null) { + for (String cookie : setCookies) { + responseHeaders.add(HttpHeaders.SET_COOKIE, cookie); + } + } + + when(restTemplate.exchange( + eq(CSRF_URL), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(Map.class) + )).thenReturn(new ResponseEntity<>(csrfBody, responseHeaders, HttpStatus.OK)); + } + + private void mockGuestTokenResponse() { + Map guestTokenBody = new HashMap<>(); + guestTokenBody.put("token", GUEST_TOKEN); + + when(restTemplate.exchange( + eq(GUEST_TOKEN_URL), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(Map.class) + )).thenReturn(new ResponseEntity<>(guestTokenBody, HttpStatus.OK)); + } +} diff --git a/src/test/java/org/openlmis/report/utils/DashboardReportDataBuilder.java b/src/test/java/org/openlmis/report/utils/DashboardReportDataBuilder.java index 9359565..8516105 100644 --- a/src/test/java/org/openlmis/report/utils/DashboardReportDataBuilder.java +++ b/src/test/java/org/openlmis/report/utils/DashboardReportDataBuilder.java @@ -24,6 +24,7 @@ public class DashboardReportDataBuilder { private final UUID id = UUID.randomUUID(); private String name = RandomStringUtils.random(6); private String url = "http://example.com"; + private String embeddedUuid = null; private ReportType type = ReportType.SUPERSET; private boolean enabled = true; private boolean showOnHomePage = false; @@ -80,6 +81,7 @@ public DashboardReport buildAsNew() { return new DashboardReport( name, url, + embeddedUuid, type, enabled, showOnHomePage, diff --git a/src/test/java/org/openlmis/report/web/SupersetGuestTokenControllerTest.java b/src/test/java/org/openlmis/report/web/SupersetGuestTokenControllerTest.java new file mode 100644 index 0000000..22dcc1b --- /dev/null +++ b/src/test/java/org/openlmis/report/web/SupersetGuestTokenControllerTest.java @@ -0,0 +1,80 @@ +/* + * This program is part of the OpenLMIS logistics management information system platform software. + * Copyright © 2017 VillageReach + * + * This program is free software: you can redistribute it and/or modify it under the terms + * of the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. You should have received a copy of + * the GNU Affero General Public License along with this program. If not, see + * http://www.gnu.org/licenses.  For additional information contact info@OpenLMIS.org. + */ + +package org.openlmis.report.web; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.when; + +import java.util.Map; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.openlmis.report.service.SupersetService; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +public class SupersetGuestTokenControllerTest { + + private static final String EMBEDDED_UUID = "test-dashboard-uuid"; + private static final String USERNAME = "testuser"; + private static final String GUEST_TOKEN = "mock-guest-token"; + + @Mock + private SupersetService supersetService; + + @InjectMocks + private SupersetGuestTokenController controller; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + } + + @After + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void shouldReturnGuestToken() { + // given + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(USERNAME, "password") + ); + + when(supersetService.getGuestToken(EMBEDDED_UUID, USERNAME, USERNAME, USERNAME)) + .thenReturn(GUEST_TOKEN); + + // when + Map result = controller.getGuestToken(EMBEDDED_UUID); + + // then + assertEquals(GUEST_TOKEN, result.get("token")); + assertEquals(1, result.size()); + } + + @Test(expected = NullPointerException.class) + public void shouldThrowWhenNotAuthenticated() { + // given - no security context / authentication set + SecurityContextHolder.clearContext(); + + // when + controller.getGuestToken(EMBEDDED_UUID); + } +} From a2bbebfd5ccf52f653a877e94e4a64e68c541a0a Mon Sep 17 00:00:00 2001 From: Damian Szafranek Date: Mon, 25 May 2026 16:59:29 +0200 Subject: [PATCH 2/2] MW-1449: Regenerate migration, localize Superset errors, gate endpoint by REPORTS_VIEW and embeddedUuid - Rename migration to a canonical now()-based timestamp via the generateMigration Gradle task - Throw a localized ServerException (with new SupersetMessageKeys) instead of including raw Superset response bodies in IllegalStateException messages - Drop the insecure admin/changeme @Value defaults; SupersetService now short-circuits with a "not configured" ServerException when superset.url/admin user/password are unset, so the service still boots for adopters who do not use Superset - Add existsByEmbeddedUuid lookup and return 404 when no dashboard report matches the requested UUID - Gate the guest-token endpoint behind permissionService.canViewReports() - Add CHANGELOG entry --- CHANGELOG.md | 3 + .../errorhandling/BaseErrorHandling.java | 8 +++ .../report/exception/ServerException.java | 33 +++++++++++ .../report/i18n/SupersetMessageKeys.java | 29 ++++++++++ .../repository/DashboardReportRepository.java | 2 + .../report/service/SupersetService.java | 22 ++++---- .../web/SupersetGuestTokenController.java | 18 ++++++ src/main/resources/application.properties | 4 +- ...dd_embedded_uuid_to_dashboard_reports.sql} | 0 src/main/resources/messages_en.properties | 6 ++ .../report/service/SupersetServiceTest.java | 23 +++++++- .../web/SupersetGuestTokenControllerTest.java | 55 ++++++++++++++----- 12 files changed, 175 insertions(+), 28 deletions(-) create mode 100644 src/main/java/org/openlmis/report/exception/ServerException.java create mode 100644 src/main/java/org/openlmis/report/i18n/SupersetMessageKeys.java rename src/main/resources/db/migration/{20260401000000000__add_embedded_uuid_to_dashboard_reports.sql => 20260525141933259__add_embedded_uuid_to_dashboard_reports.sql} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31d0a74..36b3cad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Upcoming Version / (WIP) Improvements: * Stabilized consul registration and health checks +New functionality: +* [MW-1449](https://openlmis.atlassian.net/browse/MW-1449): Added Superset guest token endpoint for embedded dashboards. Dashboard reports now carry an optional `embeddedUuid` column referencing a Superset embedded dashboard. The new `/api/reports/superset/guest-token` endpoint exchanges an OpenLMIS user for a short-lived Superset guest token, gated by the `REPORTS_VIEW` right and a lookup against the dashboard's `embeddedUuid`. + 1.5.0 / 2025-11-27 ================== diff --git a/src/main/java/org/openlmis/report/errorhandling/BaseErrorHandling.java b/src/main/java/org/openlmis/report/errorhandling/BaseErrorHandling.java index 501d06c..5ec8bad 100644 --- a/src/main/java/org/openlmis/report/errorhandling/BaseErrorHandling.java +++ b/src/main/java/org/openlmis/report/errorhandling/BaseErrorHandling.java @@ -20,6 +20,7 @@ import org.openlmis.report.exception.DataRetrievalException; import org.openlmis.report.exception.NotFoundMessageException; import org.openlmis.report.exception.PermissionMessageException; +import org.openlmis.report.exception.ServerException; import org.openlmis.report.exception.ValidationMessageException; import org.openlmis.report.i18n.MessageService; import org.openlmis.report.utils.Message; @@ -85,6 +86,13 @@ public Message.LocalizedMessage handleNotFoundMessageException(NotFoundMessageEx return getLocalizedMessage(ex); } + @ExceptionHandler(ServerException.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ResponseBody + public Message.LocalizedMessage handleServerException(ServerException ex) { + return getLocalizedMessage(ex); + } + /** * Logs an error message and returns an error response. * diff --git a/src/main/java/org/openlmis/report/exception/ServerException.java b/src/main/java/org/openlmis/report/exception/ServerException.java new file mode 100644 index 0000000..b397768 --- /dev/null +++ b/src/main/java/org/openlmis/report/exception/ServerException.java @@ -0,0 +1,33 @@ +/* + * This program is part of the OpenLMIS logistics management information system platform software. + * Copyright © 2017 VillageReach + * + * This program is free software: you can redistribute it and/or modify it under the terms + * of the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. You should have received a copy of + * the GNU Affero General Public License along with this program. If not, see + * http://www.gnu.org/licenses.  For additional information contact info@OpenLMIS.org. + */ + +package org.openlmis.report.exception; + +import org.openlmis.report.utils.Message; + +/** + * Exception representing a server-side failure that is safe to surface to the client + * as a localized message but should map to an HTTP 500 status. + */ +public class ServerException extends BaseMessageException { + + public ServerException(Message message) { + super(message); + } + + public ServerException(Message message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/openlmis/report/i18n/SupersetMessageKeys.java b/src/main/java/org/openlmis/report/i18n/SupersetMessageKeys.java new file mode 100644 index 0000000..12a61e3 --- /dev/null +++ b/src/main/java/org/openlmis/report/i18n/SupersetMessageKeys.java @@ -0,0 +1,29 @@ +/* + * This program is part of the OpenLMIS logistics management information system platform software. + * Copyright © 2017 VillageReach + * + * This program is free software: you can redistribute it and/or modify it under the terms + * of the GNU Affero General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. You should have received a copy of + * the GNU Affero General Public License along with this program. If not, see + * http://www.gnu.org/licenses.  For additional information contact info@OpenLMIS.org. + */ + +package org.openlmis.report.i18n; + +public class SupersetMessageKeys extends MessageKeys { + public static final String ERROR_SUPERSET_NOT_CONFIGURED = + "report.error.superset.notConfigured"; + public static final String ERROR_SUPERSET_LOGIN_FAILED = + "report.error.superset.login.failed"; + public static final String ERROR_SUPERSET_CSRF_FAILED = + "report.error.superset.csrf.failed"; + public static final String ERROR_SUPERSET_GUEST_TOKEN_FAILED = + "report.error.superset.guestToken.failed"; + public static final String ERROR_SUPERSET_DASHBOARD_NOT_FOUND = + "report.error.superset.dashboard.notFound"; +} diff --git a/src/main/java/org/openlmis/report/repository/DashboardReportRepository.java b/src/main/java/org/openlmis/report/repository/DashboardReportRepository.java index 5b418e3..14b9b42 100644 --- a/src/main/java/org/openlmis/report/repository/DashboardReportRepository.java +++ b/src/main/java/org/openlmis/report/repository/DashboardReportRepository.java @@ -31,6 +31,8 @@ public interface DashboardReportRepository boolean existsByName(String name); + boolean existsByEmbeddedUuid(String embeddedUuid); + List findByShowOnHomePage(boolean showOnHomePage); boolean existsByCategory_Id(UUID categoryId); diff --git a/src/main/java/org/openlmis/report/service/SupersetService.java b/src/main/java/org/openlmis/report/service/SupersetService.java index 1f8927f..0dc6175 100644 --- a/src/main/java/org/openlmis/report/service/SupersetService.java +++ b/src/main/java/org/openlmis/report/service/SupersetService.java @@ -19,8 +19,9 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.openlmis.report.exception.ServerException; +import org.openlmis.report.i18n.SupersetMessageKeys; +import org.openlmis.report.utils.Message; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -38,15 +39,13 @@ @Service public class SupersetService { - private static final Logger LOGGER = LoggerFactory.getLogger(SupersetService.class); - @Value("${superset.url:}") private String supersetUrl; - @Value("${superset.admin.user:admin}") + @Value("${superset.admin.user:}") private String adminUser; - @Value("${superset.admin.password:changeme}") + @Value("${superset.admin.password:}") private String adminPassword; private final RestTemplate restTemplate = new RestTemplate(); @@ -65,11 +64,13 @@ public class SupersetService { */ public String getGuestToken(String embeddedUuid, String username, String firstName, String lastName) { + if (supersetUrl.isEmpty() || adminUser.isEmpty() || adminPassword.isEmpty()) { + throw new ServerException(new Message(SupersetMessageKeys.ERROR_SUPERSET_NOT_CONFIGURED)); + } try { return requestGuestToken(embeddedUuid, username, firstName, lastName); } catch (HttpClientErrorException ex) { if (ex.getStatusCode().value() == 401) { - LOGGER.info("Superset access token expired, re-authenticating"); clearCachedToken(); return requestGuestToken(embeddedUuid, username, firstName, lastName); } @@ -119,7 +120,7 @@ private String requestGuestToken(String embeddedUuid, String username, Map responseBody = response.getBody(); if (responseBody == null || !responseBody.containsKey("token")) { - throw new IllegalStateException("Unexpected Superset guest_token response: " + responseBody); + throw new ServerException(new Message(SupersetMessageKeys.ERROR_SUPERSET_GUEST_TOKEN_FAILED)); } return (String) responseBody.get("token"); } @@ -152,13 +153,12 @@ private String login() { Map loginResponse = response.getBody(); if (loginResponse == null || !loginResponse.containsKey("access_token")) { - throw new IllegalStateException("Unexpected Superset login response: " + loginResponse); + throw new ServerException(new Message(SupersetMessageKeys.ERROR_SUPERSET_LOGIN_FAILED)); } cachedAccessToken = (String) loginResponse.get("access_token"); // Cache token for 4 minutes (Superset default expiry is 5 minutes) tokenExpiresAt = System.currentTimeMillis() + (4 * 60 * 1000); - LOGGER.debug("Successfully obtained Superset access token"); return cachedAccessToken; } @@ -177,7 +177,7 @@ private CsrfResult getCsrfToken(String accessToken) { Map csrfBody = response.getBody(); if (csrfBody == null || !csrfBody.containsKey("result")) { - throw new IllegalStateException("Unexpected Superset CSRF response: " + csrfBody); + throw new ServerException(new Message(SupersetMessageKeys.ERROR_SUPERSET_CSRF_FAILED)); } String csrfToken = (String) csrfBody.get("result"); diff --git a/src/main/java/org/openlmis/report/web/SupersetGuestTokenController.java b/src/main/java/org/openlmis/report/web/SupersetGuestTokenController.java index 1ed54f5..1ad633b 100644 --- a/src/main/java/org/openlmis/report/web/SupersetGuestTokenController.java +++ b/src/main/java/org/openlmis/report/web/SupersetGuestTokenController.java @@ -17,7 +17,12 @@ import java.util.Collections; import java.util.Map; +import org.openlmis.report.exception.NotFoundMessageException; +import org.openlmis.report.i18n.SupersetMessageKeys; +import org.openlmis.report.repository.DashboardReportRepository; +import org.openlmis.report.service.PermissionService; import org.openlmis.report.service.SupersetService; +import org.openlmis.report.utils.Message; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.security.core.context.SecurityContextHolder; @@ -35,6 +40,12 @@ public class SupersetGuestTokenController extends BaseController { @Autowired private SupersetService supersetService; + @Autowired + private DashboardReportRepository dashboardReportRepository; + + @Autowired + private PermissionService permissionService; + /** * Get a Superset guest token for embedding a dashboard. * @@ -45,6 +56,13 @@ public class SupersetGuestTokenController extends BaseController { @ResponseStatus(HttpStatus.OK) @ResponseBody public Map getGuestToken(@RequestParam String embeddedUuid) { + permissionService.canViewReports(); + + if (!dashboardReportRepository.existsByEmbeddedUuid(embeddedUuid)) { + throw new NotFoundMessageException( + new Message(SupersetMessageKeys.ERROR_SUPERSET_DASHBOARD_NOT_FOUND, embeddedUuid)); + } + String username = SecurityContextHolder.getContext().getAuthentication().getName(); String token = supersetService.getGuestToken( diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d82b0d8..5003c2e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -60,5 +60,5 @@ requisition.url=${BASE_URL} fulfillment.url=${BASE_URL} superset.url=${SUPERSET_URL:} -superset.admin.user=${SUPERSET_ADMIN_USER:admin} -superset.admin.password=${SUPERSET_ADMIN_PASSWORD:changeme} +superset.admin.user=${SUPERSET_ADMIN_USER:} +superset.admin.password=${SUPERSET_ADMIN_PASSWORD:} diff --git a/src/main/resources/db/migration/20260401000000000__add_embedded_uuid_to_dashboard_reports.sql b/src/main/resources/db/migration/20260525141933259__add_embedded_uuid_to_dashboard_reports.sql similarity index 100% rename from src/main/resources/db/migration/20260401000000000__add_embedded_uuid_to_dashboard_reports.sql rename to src/main/resources/db/migration/20260525141933259__add_embedded_uuid_to_dashboard_reports.sql diff --git a/src/main/resources/messages_en.properties b/src/main/resources/messages_en.properties index c1501b2..fa4a5bf 100644 --- a/src/main/resources/messages_en.properties +++ b/src/main/resources/messages_en.properties @@ -17,3 +17,9 @@ report.error.reportCategory.name.duplicated=Error. Report category name duplicat report.error.reportCategory.id.mismatch=Error. Report category id mismatch. report.error.reportCategory.notFound=Report category with id {0} not found. report.error.reportCategory.already.assigned=Report category cannot be deleted since it is assigned. {0} + +report.error.superset.notConfigured=Superset integration is not configured on this server. +report.error.superset.login.failed=Failed to authenticate against Superset. +report.error.superset.csrf.failed=Failed to obtain a CSRF token from Superset. +report.error.superset.guestToken.failed=Failed to obtain a Superset guest token. +report.error.superset.dashboard.notFound=No dashboard report is configured for the given embedded UUID. diff --git a/src/test/java/org/openlmis/report/service/SupersetServiceTest.java b/src/test/java/org/openlmis/report/service/SupersetServiceTest.java index 11c2a72..ea47461 100644 --- a/src/test/java/org/openlmis/report/service/SupersetServiceTest.java +++ b/src/test/java/org/openlmis/report/service/SupersetServiceTest.java @@ -16,6 +16,7 @@ package org.openlmis.report.service; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.times; @@ -31,6 +32,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.runners.MockitoJUnitRunner; +import org.openlmis.report.exception.ServerException; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -153,7 +155,24 @@ public void shouldRetryOnUnauthorized() { ); } - @Test(expected = IllegalStateException.class) + @Test + public void shouldThrowWhenSupersetIsNotConfigured() { + for (String field : new String[] {"supersetUrl", "adminUser", "adminPassword"}) { + setUp(); + ReflectionTestUtils.setField(supersetService, field, ""); + ServerException thrown = null; + try { + supersetService.getGuestToken(EMBEDDED_UUID, USERNAME, USERNAME, USERNAME); + } catch (ServerException ex) { + thrown = ex; + } + if (thrown == null) { + fail("Expected ServerException when " + field + " is empty"); + } + } + } + + @Test(expected = ServerException.class) public void shouldThrowOnNullLoginResponse() { // given when(restTemplate.exchange( @@ -167,7 +186,7 @@ public void shouldThrowOnNullLoginResponse() { supersetService.getGuestToken(EMBEDDED_UUID, USERNAME, USERNAME, USERNAME); } - @Test(expected = IllegalStateException.class) + @Test(expected = ServerException.class) public void shouldThrowOnNullGuestTokenResponse() { // given mockLoginResponse(); diff --git a/src/test/java/org/openlmis/report/web/SupersetGuestTokenControllerTest.java b/src/test/java/org/openlmis/report/web/SupersetGuestTokenControllerTest.java index 22dcc1b..14c5e1d 100644 --- a/src/test/java/org/openlmis/report/web/SupersetGuestTokenControllerTest.java +++ b/src/test/java/org/openlmis/report/web/SupersetGuestTokenControllerTest.java @@ -16,6 +16,9 @@ package org.openlmis.report.web; import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.Map; @@ -25,7 +28,12 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.openlmis.report.exception.NotFoundMessageException; +import org.openlmis.report.exception.PermissionMessageException; +import org.openlmis.report.repository.DashboardReportRepository; +import org.openlmis.report.service.PermissionService; import org.openlmis.report.service.SupersetService; +import org.openlmis.report.utils.Message; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; @@ -38,12 +46,21 @@ public class SupersetGuestTokenControllerTest { @Mock private SupersetService supersetService; + @Mock + private DashboardReportRepository dashboardReportRepository; + + @Mock + private PermissionService permissionService; + @InjectMocks private SupersetGuestTokenController controller; @Before public void setUp() { MockitoAnnotations.initMocks(this); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(USERNAME, "password") + ); } @After @@ -53,28 +70,40 @@ public void tearDown() { @Test public void shouldReturnGuestToken() { - // given - SecurityContextHolder.getContext().setAuthentication( - new UsernamePasswordAuthenticationToken(USERNAME, "password") - ); - + when(dashboardReportRepository.existsByEmbeddedUuid(EMBEDDED_UUID)).thenReturn(true); when(supersetService.getGuestToken(EMBEDDED_UUID, USERNAME, USERNAME, USERNAME)) .thenReturn(GUEST_TOKEN); - // when Map result = controller.getGuestToken(EMBEDDED_UUID); - // then assertEquals(GUEST_TOKEN, result.get("token")); assertEquals(1, result.size()); + verify(permissionService).canViewReports(); } - @Test(expected = NullPointerException.class) - public void shouldThrowWhenNotAuthenticated() { - // given - no security context / authentication set - SecurityContextHolder.clearContext(); + @Test(expected = NotFoundMessageException.class) + public void shouldThrowNotFoundWhenDashboardDoesNotExist() { + when(dashboardReportRepository.existsByEmbeddedUuid(EMBEDDED_UUID)).thenReturn(false); + + try { + controller.getGuestToken(EMBEDDED_UUID); + } finally { + verify(supersetService, never()).getGuestToken( + EMBEDDED_UUID, USERNAME, USERNAME, USERNAME); + } + } + + @Test(expected = PermissionMessageException.class) + public void shouldThrowWhenUserLacksReportsViewRight() { + doThrow(new PermissionMessageException(new Message("report.error.noPermission"))) + .when(permissionService).canViewReports(); - // when - controller.getGuestToken(EMBEDDED_UUID); + try { + controller.getGuestToken(EMBEDDED_UUID); + } finally { + verify(dashboardReportRepository, never()).existsByEmbeddedUuid(EMBEDDED_UUID); + verify(supersetService, never()).getGuestToken( + EMBEDDED_UUID, USERNAME, USERNAME, USERNAME); + } } }