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
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import org.commcare.formplayer.beans.debugger.MenuDebuggerContentResponseBean;
import org.commcare.formplayer.beans.debugger.XPathQueryItem;
import org.commcare.formplayer.beans.menus.BaseResponseBean;
import org.commcare.formplayer.exceptions.PermissionDeniedException;
import org.commcare.formplayer.objects.SerializableFormSession;
import org.commcare.formplayer.objects.SerializableMenuSession;
import org.commcare.formplayer.services.FormattedQuestionsService;
Expand All @@ -21,6 +22,7 @@
import org.commcare.formplayer.session.MenuSession;
import org.commcare.formplayer.util.Constants;
import org.commcare.formplayer.util.NotificationLogger;
import org.commcare.formplayer.util.RequestUtils;
import org.javarosa.xpath.expr.FunctionUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.ListOperations;
Expand Down Expand Up @@ -66,6 +68,7 @@ public class DebuggerController extends AbstractBaseController {
public DebuggerFormattedQuestionsResponseBean getFormattedQuesitons(
@RequestBody SessionRequestBean debuggerRequest,
@CookieValue(Constants.POSTGRES_DJANGO_SESSION_ID) String authToken) throws Exception {
requireEditDataPermission();
SerializableFormSession serializableFormSession = formSessionService.getSessionById(debuggerRequest.getSessionId());
SerializableMenuSession serializableMenuSession = menuSessionService.getSessionById(serializableFormSession.getMenuSessionId());
FormSession formSession = formSessionFactory.getFormSession(serializableFormSession, debuggerRequest.getWindowWidth());
Expand Down Expand Up @@ -95,6 +98,7 @@ public MenuDebuggerContentResponseBean menuDebuggerContent(
@RequestBody SessionNavigationBean debuggerMenuRequest,
@CookieValue(Constants.POSTGRES_DJANGO_SESSION_ID) String authToken,
HttpServletRequest request) throws Exception {
requireEditDataPermission();

MenuSession menuSession = menuSessionFactory.getMenuSessionFromBean(debuggerMenuRequest);
BaseResponseBean responseBean = runnerService.advanceSessionWithSelections(
Expand All @@ -117,6 +121,7 @@ public MenuDebuggerContentResponseBean menuDebuggerContent(
public EvaluateXPathResponseBean menuEvaluateXpath(@RequestBody EvaluateXPathMenuRequestBean evaluateXPathRequestBean,
@CookieValue(Constants.POSTGRES_DJANGO_SESSION_ID) String authToken,
HttpServletRequest request) throws Exception {
requireEditDataPermission();
MenuSession menuSession = menuSessionFactory.getMenuSessionFromBean(evaluateXPathRequestBean);
BaseResponseBean responseBean = runnerService.advanceSessionWithSelections(
menuSession, evaluateXPathRequestBean.getSelections(),
Expand Down Expand Up @@ -147,6 +152,7 @@ public EvaluateXPathResponseBean menuEvaluateXpath(@RequestBody EvaluateXPathMen
@ConfigureStorageFromSession
public EvaluateXPathResponseBean evaluateXpath(@RequestBody EvaluateXPathRequestBean evaluateXPathRequestBean,
@CookieValue(Constants.POSTGRES_DJANGO_SESSION_ID) String authToken) throws Exception {
requireEditDataPermission();
SerializableFormSession serializableFormSession = formSessionService.getSessionById(evaluateXPathRequestBean.getSessionId());
FormSession formEntrySession = formSessionFactory.getFormSession(serializableFormSession, evaluateXPathRequestBean.getWindowWidth());
EvaluateXPathResponseBean evaluateXPathResponseBean = new EvaluateXPathResponseBean(
Expand All @@ -166,6 +172,15 @@ public EvaluateXPathResponseBean evaluateXpath(@RequestBody EvaluateXPathRequest
return evaluateXPathResponseBean;
}

void requireEditDataPermission() {
boolean granted = RequestUtils.getUserDetails()
.map(details -> details.hasPermission(Constants.PERMISSION_EDIT_DATA))
.orElse(false);
if (!granted) {
throw new PermissionDeniedException(Constants.PERMISSION_EDIT_DATA);
}
}

private List<XPathQueryItem> fetchRecentMenuXPathQueries(String domain, String username) {
return fetchRecentXPathQueries("menu", domain, username);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import org.commcare.formplayer.exceptions.FormNotFoundException;
import org.commcare.formplayer.exceptions.FormattedApplicationConfigException;
import org.commcare.formplayer.exceptions.InterruptedRuntimeException;
import org.commcare.formplayer.exceptions.PermissionDeniedException;
import org.commcare.formplayer.exceptions.UnresolvedResourceRuntimeException;
import org.commcare.formplayer.util.Constants;
import org.commcare.formplayer.util.FormplayerDatadog;
Expand Down Expand Up @@ -132,6 +133,14 @@ public ExceptionResponseBean handleLockError(HttpServletRequest req, Exception e
return new ExceptionResponseBean("User lock timed out", req.getRequestURL().toString());
}

@ExceptionHandler({PermissionDeniedException.class})
@ResponseBody
@ResponseStatus(HttpStatus.FORBIDDEN)
public ExceptionResponseBean handlePermissionDenied(HttpServletRequest req, PermissionDeniedException exception) {
log.info("Permission denied", exception);
return new ExceptionResponseBean(exception.getMessage(), req.getRequestURL().toString());
}

@ExceptionHandler({InterruptedRuntimeException.class})
@ResponseBody
public ExceptionResponseBean handleInterruptException(HttpServletRequest req, Exception exception) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public class HqUserDetailsBean implements UserDetails {
private String domain;
private String[] enabledToggles;
private String[] enabledPreviews;
private String[] permissions;

// HQ marks public web apps sessions with a JSON `public` field. `public` is a reserved word,
// so map it to this property. Primitive boolean so missing JSON defaults to false.
Expand Down Expand Up @@ -70,6 +71,10 @@ public boolean isAuthorizedForDomain(String domain) {
return Arrays.asList(domains).contains(domain);
}

public boolean hasPermission(String permission) {
return permissions != null && Arrays.asList(permissions).contains(permission);
}

/////////////////////// UserDetails methods

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.commcare.formplayer.exceptions;

/**
* Thrown when the authenticated user lacks permission for the requested action.
*/
public class PermissionDeniedException extends RuntimeException {
public PermissionDeniedException(String permission) {
super("This action requires the '" + permission + "' permission.");
}
}
4 changes: 4 additions & 0 deletions src/main/java/org/commcare/formplayer/util/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -210,4 +210,8 @@ public static final class TimingCategories {
public static final String TOGGLE_INCLUDE_STATE_HASH = "FORMPLAYER_INCLUDE_STATE_HASH";

public static final String AUTHORITY_COMMCARE = "COMMCARE";

// HQ domain permissions
// These correspond to property names of commcare-hq corehq/apps/users/models.py HqPermissions
public static final String PERMISSION_EDIT_DATA = "edit_data";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package org.commcare.formplayer.application;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mockStatic;

import org.commcare.formplayer.beans.auth.HqUserDetailsBean;
import org.commcare.formplayer.exceptions.PermissionDeniedException;
import org.commcare.formplayer.util.RequestUtils;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;

import java.util.Optional;

public class DebuggerControllerTest {

private final DebuggerController controller = new DebuggerController();

@Test
public void deniedWhenEditDataPermissionMissing() {
try (MockedStatic<RequestUtils> mocked = mockStatic(RequestUtils.class)) {
mocked.when(RequestUtils::getUserDetails)
.thenReturn(Optional.of(beanWithPermissions("access_web_apps")));
assertThrows(PermissionDeniedException.class, controller::requireEditDataPermission);
}
}

@Test
public void deniedWhenNoAuthenticatedUser() {
try (MockedStatic<RequestUtils> mocked = mockStatic(RequestUtils.class)) {
mocked.when(RequestUtils::getUserDetails).thenReturn(Optional.empty());
assertThrows(PermissionDeniedException.class, controller::requireEditDataPermission);
}
}

@Test
public void grantedWhenEditDataPermissionPresent() {
try (MockedStatic<RequestUtils> mocked = mockStatic(RequestUtils.class)) {
mocked.when(RequestUtils::getUserDetails)
.thenReturn(Optional.of(beanWithPermissions("edit_data")));
assertDoesNotThrow(controller::requireEditDataPermission);
}
}

private HqUserDetailsBean beanWithPermissions(String... permissions) {
HqUserDetailsBean bean = new HqUserDetailsBean("domain", "aragorn");
bean.setPermissions(permissions);
return bean;
}
}
47 changes: 46 additions & 1 deletion src/test/java/org/commcare/formplayer/tests/BaseTestClass.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@
import org.commcare.formplayer.application.FormSessionFactory;
import org.commcare.formplayer.application.FormSubmissionController;
import org.commcare.formplayer.application.FormSubmissionHelper;
import org.commcare.formplayer.application.GlobalDefaultExceptionHandler;
import org.commcare.formplayer.application.MenuController;
import org.commcare.formplayer.application.SQLiteProperties;
import org.commcare.formplayer.application.UtilController;
import org.commcare.formplayer.auth.DjangoAuth;
import org.commcare.formplayer.beans.AnswerQuestionRequestBean;
import org.commcare.formplayer.beans.AuthenticatedRequestBean;
import org.commcare.formplayer.beans.auth.HqUserDetailsBean;
import org.commcare.formplayer.beans.ChangeLocaleRequestBean;
import org.commcare.formplayer.beans.DeleteApplicationDbsRequestBean;
import org.commcare.formplayer.beans.EvaluateXPathMenuRequestBean;
Expand Down Expand Up @@ -70,6 +72,7 @@
import org.commcare.formplayer.services.CategoryTimingHelper;
import org.commcare.formplayer.services.FormDefinitionService;
import org.commcare.formplayer.services.FormSessionService;
import org.commcare.formplayer.services.FormattedQuestionsService;
import org.commcare.formplayer.services.FormplayerRemoteInstanceFetcher;
import org.commcare.formplayer.services.FormplayerStorageFactory;
import org.commcare.formplayer.services.InstallService;
Expand All @@ -86,9 +89,12 @@
import org.commcare.formplayer.util.Constants;
import org.commcare.formplayer.util.FormplayerDatadog;
import org.commcare.formplayer.util.NotificationLogger;
import org.commcare.formplayer.util.RequestUtils;
import org.commcare.formplayer.util.serializer.SessionSerializer;
import org.commcare.formplayer.utils.FileUtils;
import org.commcare.formplayer.utils.HqUserDetails;
import org.commcare.formplayer.utils.TestContext;
import org.commcare.formplayer.utils.WithHqUserSecurityContextFactory;
import org.commcare.formplayer.web.client.WebClient;
import org.commcare.modern.util.Pair;
import org.commcare.session.CommCareSession;
Expand Down Expand Up @@ -119,6 +125,7 @@
import org.springframework.integration.support.locks.LockRegistry;
import org.springframework.lang.Nullable;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
Expand All @@ -132,6 +139,7 @@
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;

import jakarta.servlet.http.Cookie;
Expand Down Expand Up @@ -194,6 +202,9 @@ public class BaseTestClass {
@Autowired
private NewFormResponseFactory newFormResponseFactoryMock;

@Autowired
protected FormattedQuestionsService formattedQuestionsServiceMock;

@Autowired
protected FormplayerDatadog datadogMock;

Expand Down Expand Up @@ -270,6 +281,7 @@ public void setUp() throws Exception {
Mockito.reset(installService);
Mockito.reset(userLockRegistry);
Mockito.reset(newFormResponseFactoryMock);
Mockito.reset(formattedQuestionsServiceMock);
Mockito.reset(storageFactoryMock);
Mockito.reset(formplayerInstallerFactory);
Mockito.reset(datadogMock);
Expand All @@ -283,7 +295,11 @@ public void setUp() throws Exception {
formSubmissionController).build();
mockUtilController = MockMvcBuilders.standaloneSetup(utilController).build();
mockMenuController = MockMvcBuilders.standaloneSetup(menuController).build();
mockDebuggerController = MockMvcBuilders.standaloneSetup(debuggerController).build();
GlobalDefaultExceptionHandler exceptionHandler = new GlobalDefaultExceptionHandler();
ReflectionTestUtils.setField(exceptionHandler, "datadog", datadogMock);
mockDebuggerController = MockMvcBuilders.standaloneSetup(debuggerController)
.setControllerAdvice(exceptionHandler)
.build();
setupRestoreFactoryMock();
setupSubmitServiceMock();
mapper = new ObjectMapper();
Expand All @@ -294,6 +310,18 @@ public void setUp() throws Exception {
mockVirtualDataInstanceService();
// this shouldn't be needed here (see TestContext) but tests fail without it
new SQLiteProperties().setDataDir("testdbs/");
setEditDataPermission(true);
}

protected void setEditDataPermission(boolean granted) {
String[] permissions = granted ? new String[]{Constants.PERMISSION_EDIT_DATA} : new String[]{};
Optional<HqUserDetailsBean> existing = RequestUtils.getUserDetails();
if (existing.isPresent()) {
existing.get().setPermissions(permissions);
} else {
WithHqUserSecurityContextFactory.setSecurityContext(
HqUserDetails.builder().permissions(permissions).build());
}
}

private void setupRestoreFactoryMock() {
Expand Down Expand Up @@ -416,6 +444,7 @@ public void tearDown() throws SQLException {
}
ReferenceHandler.clearInstance();
LocalizerManager.clearInstance();
SecurityContextHolder.clearContext();
}

private UserDB customConnector;
Expand Down Expand Up @@ -643,6 +672,22 @@ EvaluateXPathResponseBean evaluateXPath(String sessionId, String xPath) throws E
.bean();
}

ResultActions postDebuggerRequest(String urlPath, Object bean) throws Exception {
return mockDebuggerController.perform(post(urlPrepend(urlPath))
.contentType(MediaType.APPLICATION_JSON)
.cookie(new Cookie(Constants.POSTGRES_DJANGO_SESSION_ID, "derp"))
.content(mapper.writeValueAsString(bean)));
}

ResultActions postDebuggerRequestWithInstallReference(String requestPath, String urlPath,
Class<?> beanClass) throws Exception {
Pair<String, ?> refAndBean = Installer.getInstallReferenceAndBean(requestPath, beanClass);
return Installer.mockInstallReference(
() -> postDebuggerRequest(urlPath, refAndBean.second),
refAndBean.first
);
}

EvaluateXPathResponseBean evaluateMenuXpath(String requestPath) throws Exception {
Pair<String, EvaluateXPathMenuRequestBean> refAndBean = Installer.getInstallReferenceAndBean(
requestPath, EvaluateXPathMenuRequestBean.class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ public void whenCallingGetUserDetails_thenClientMakesCorrectCall()
"\"djangoUserId\":1," +
"\"username\":\"user@domain.commcarehq.org\"," +
"\"authToken\":\"authToke\"," +
"\"superUser\":false" +
"\"superUser\":false," +
"\"permissions\":[\"edit_data\"]" +
"}";

this.server.expect(requestTo(Constants.SESSION_DETAILS_VIEW))
Expand All @@ -107,6 +108,7 @@ public void whenCallingGetUserDetails_thenClientMakesCorrectCall()

assertThat(details.getUsername()).isEqualTo("user@domain.commcarehq.org");
assertThat(details.getDomains()).isEqualTo(new String[]{"domain"});
assertThat(details.getPermissions()).containsExactly("edit_data");
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,24 @@ public void testPublicSessionDeserialization() throws Exception {
Assertions.assertNull(noField.getPublicEndpointId());
}

@Test
public void testHasPermission() {
HqUserDetailsBean user = new HqUserDetailsBean("domain",
new String[]{"domain"}, "aragorn",
false, new String[]{}, new String[]{});
user.setPermissions(new String[]{"edit_data"});

Assertions.assertTrue(user.hasPermission("edit_data"));
Assertions.assertFalse(user.hasPermission("access_web_apps"));
}

@Test
public void testHasPermissionWhenPermissionsMissing() {
HqUserDetailsBean user = new HqUserDetailsBean("domain", "aragorn");

Assertions.assertFalse(user.hasPermission("edit_data"));
}

@Test
public void testFeatureFlagChecker_isToggleEnabled() {
WithHqUserSecurityContextFactory.setSecurityContext(
Expand Down
Loading
Loading