Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
==================

Expand Down
9 changes: 9 additions & 0 deletions src/main/java/org/openlmis/report/domain/DashboardReport.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand All @@ -114,6 +119,8 @@ public interface Exporter {

void setUrl(String url);

void setEmbeddedUuid(String embeddedUuid);

void setType(ReportType type);

void setEnabled(boolean enabled);
Expand All @@ -132,6 +139,8 @@ public interface Importer {

String getUrl();

String getEmbeddedUuid();

ReportType getType();

boolean isEnabled();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down
33 changes: 33 additions & 0 deletions src/main/java/org/openlmis/report/exception/ServerException.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
29 changes: 29 additions & 0 deletions src/main/java/org/openlmis/report/i18n/SupersetMessageKeys.java
Original file line number Diff line number Diff line change
@@ -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";
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ public interface DashboardReportRepository

boolean existsByName(String name);

boolean existsByEmbeddedUuid(String embeddedUuid);

List<DashboardReport> findByShowOnHomePage(boolean showOnHomePage);

boolean existsByCategory_Id(UUID categoryId);
Expand Down
225 changes: 225 additions & 0 deletions src/main/java/org/openlmis/report/service/SupersetService.java
Original file line number Diff line number Diff line change
@@ -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.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;
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 {

@Value("${superset.url:}")
private String supersetUrl;

@Value("${superset.admin.user:}")
private String adminUser;

@Value("${superset.admin.password:}")
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) {
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) {
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<String, Object> user = new HashMap<>();
user.put("username", username);
user.put("first_name", firstName);
user.put("last_name", lastName);

Map<String, Object> resource = new HashMap<>();
resource.put("type", "dashboard");
resource.put("id", embeddedUuid);

Map<String, Object> body = new HashMap<>();
body.put("user", user);
body.put("resources", Collections.singletonList(resource));
body.put("rls", Collections.emptyList());

HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);

ResponseEntity<Map> response = restTemplate.exchange(

Check warning on line 114 in src/main/java/org/openlmis/report/service/SupersetService.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Provide the parametrized type for this generic.

See more on https://sonarcloud.io/project/issues?id=OpenLMIS_openlmis-report&issues=AZ5fqhy-0LBfYcx7HEQS&open=AZ5fqhy-0LBfYcx7HEQS&pullRequest=23
supersetUrl + "/api/v1/security/guest_token/",
HttpMethod.POST,
request,
Map.class
);

Map responseBody = response.getBody();

Check warning on line 121 in src/main/java/org/openlmis/report/service/SupersetService.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Provide the parametrized type for this generic.

See more on https://sonarcloud.io/project/issues?id=OpenLMIS_openlmis-report&issues=AZ5fqhy-0LBfYcx7HEQT&open=AZ5fqhy-0LBfYcx7HEQT&pullRequest=23
if (responseBody == null || !responseBody.containsKey("token")) {
throw new ServerException(new Message(SupersetMessageKeys.ERROR_SUPERSET_GUEST_TOKEN_FAILED));
}
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<String, String> body = new HashMap<>();
body.put("username", adminUser);
body.put("password", adminPassword);
body.put("provider", "db");
body.put("refresh", "true");

HttpEntity<Map<String, String>> request = new HttpEntity<>(body, headers);

ResponseEntity<Map> response = restTemplate.exchange(

Check warning on line 147 in src/main/java/org/openlmis/report/service/SupersetService.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Provide the parametrized type for this generic.

See more on https://sonarcloud.io/project/issues?id=OpenLMIS_openlmis-report&issues=AZ5fqhy-0LBfYcx7HEQU&open=AZ5fqhy-0LBfYcx7HEQU&pullRequest=23
supersetUrl + "/api/v1/security/login",
HttpMethod.POST,
request,
Map.class
);

Map loginResponse = response.getBody();

Check warning on line 154 in src/main/java/org/openlmis/report/service/SupersetService.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Provide the parametrized type for this generic.

See more on https://sonarcloud.io/project/issues?id=OpenLMIS_openlmis-report&issues=AZ5fqhy-0LBfYcx7HEQV&open=AZ5fqhy-0LBfYcx7HEQV&pullRequest=23
if (loginResponse == null || !loginResponse.containsKey("access_token")) {
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);

return cachedAccessToken;
}

private CsrfResult getCsrfToken(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + accessToken);

HttpEntity<Void> request = new HttpEntity<>(headers);

ResponseEntity<Map> response = restTemplate.exchange(

Check warning on line 171 in src/main/java/org/openlmis/report/service/SupersetService.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Provide the parametrized type for this generic.

See more on https://sonarcloud.io/project/issues?id=OpenLMIS_openlmis-report&issues=AZ5fqhy-0LBfYcx7HEQW&open=AZ5fqhy-0LBfYcx7HEQW&pullRequest=23
supersetUrl + "/api/v1/security/csrf_token/",
HttpMethod.GET,
request,
Map.class
);

Map csrfBody = response.getBody();

Check warning on line 178 in src/main/java/org/openlmis/report/service/SupersetService.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Provide the parametrized type for this generic.

See more on https://sonarcloud.io/project/issues?id=OpenLMIS_openlmis-report&issues=AZ5fqhy-0LBfYcx7HEQX&open=AZ5fqhy-0LBfYcx7HEQX&pullRequest=23
if (csrfBody == null || !csrfBody.containsKey("result")) {
throw new ServerException(new Message(SupersetMessageKeys.ERROR_SUPERSET_CSRF_FAILED));
}
String csrfToken = (String) csrfBody.get("result");

// Extract Set-Cookie headers to send back with the guest token request
List<String> 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;
}
}
}
Loading
Loading