Skip to content

Commit

Permalink
feat: Add read ACL check when no namespace protection [DHS2-16134] (#…
Browse files Browse the repository at this point in the history
…15754)

* feature: Start to add tests for new behaviour [DHIS2-16134]

* feat: Add test for default behaviour [DHIS2-16134]

* feat: Add tests for datastore entry sharing [DHIS2-16134]

* feat: Add tests for datastore entry metadata sharing [DHIS2-16134]

* feat: Cover sharing scenario and update tests for datastore entry sharing [DHIS2-16134]

* feat: Add javadoc and update tests [DHIS2-16134]
  • Loading branch information
david-mackessy authored Nov 27, 2023
1 parent ae627d0 commit b3e8bf3
Show file tree
Hide file tree
Showing 3 changed files with 368 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
import org.hisp.dhis.security.acl.AclService;
import org.hisp.dhis.user.CurrentUserService;
import org.hisp.dhis.user.User;
import org.hisp.dhis.user.sharing.Sharing;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand Down Expand Up @@ -169,13 +170,33 @@ public void deleteEntry(DatastoreEntry entry) {
writeProtectedIn(entry.getNamespace(), () -> singletonList(entry), () -> store.delete(entry));
}

/**
* There are 2 levels of access to be aware of in a Datastore: <br>
*
* <ol>
* <li>{@link DatastoreNamespaceProtection}
* <ul>
* <li>this is currently only set programmatically
* <li>new namespaces setup through the API will have no {@link
* DatastoreNamespaceProtection}
* </ul>
* <li>standard {@link Sharing}
* </ol>
*
* @param namespace namespace
* @param whenHidden value to return when namespace is hidden & no access
* @param read data supplier
* @return data supplier value or whenHidden value
* @throws AccessDeniedException if {@link User} has no {@link Sharing} access to {@link
* DatastoreEntry} or {@link User} has no {@link Sharing} access for restricted namespace
* {@link DatastoreEntry}
*/
private <T> T readProtectedIn(String namespace, T whenHidden, Supplier<T> read) {
DatastoreNamespaceProtection protection = protectionByNamespace.get(namespace);
if (protection == null
|| protection.getReads() == ProtectionType.NONE
|| currentUserHasAuthority(protection.getAuthorities())) {
if (userHasNamespaceReadAccess(protection)) {
T res = read.get();
if (res instanceof DatastoreEntry && protection != null && protection.isSharingRespected()) {
if (isDatastoreEntryAndProtectionSharingRespected(res, protection)
|| (isDatastoreEntryWithNoProtection(res, protection))) {
DatastoreEntry entry = (DatastoreEntry) res;
if (!aclService.canRead(currentUserService.getCurrentUser(), entry)) {
throw new AccessDeniedException(
Expand All @@ -190,6 +211,23 @@ private <T> T readProtectedIn(String namespace, T whenHidden, Supplier<T> read)
return whenHidden;
}

private <T> boolean isDatastoreEntryWithNoProtection(
T res, DatastoreNamespaceProtection protection) {
return (res instanceof DatastoreEntry) && (protection == null);
}

private <T> boolean isDatastoreEntryAndProtectionSharingRespected(
T res, DatastoreNamespaceProtection protection) {
return (res instanceof DatastoreEntry)
&& (protection != null && protection.isSharingRespected());
}

private boolean userHasNamespaceReadAccess(DatastoreNamespaceProtection protection) {
return protection == null
|| protection.getReads() == ProtectionType.NONE
|| currentUserHasAuthority(protection.getAuthorities());
}

private void writeProtectedIn(
String namespace, Supplier<List<DatastoreEntry>> whenSharing, Runnable write) {
DatastoreNamespaceProtection protection = protectionByNamespace.get(namespace);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
/*
* Copyright (c) 2004-2023, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* Neither the name of the HISP project nor the names of its contributors may
* be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.hisp.dhis.datastore;

import static org.hisp.dhis.datastore.DatastoreKeysTest.newEntry;
import static org.hisp.dhis.datastore.DatastoreKeysTest.sharingNoPublicAccess;
import static org.hisp.dhis.datastore.DatastoreKeysTest.sharingUserAccess;
import static org.hisp.dhis.datastore.DatastoreKeysTest.sharingUserGroupAccess;
import static org.junit.jupiter.api.Assertions.assertEquals;

import com.google.gson.JsonObject;
import org.hisp.dhis.ApiTest;
import org.hisp.dhis.actions.LoginActions;
import org.hisp.dhis.actions.RestApiActions;
import org.hisp.dhis.actions.UserActions;
import org.hisp.dhis.dto.ApiResponse;
import org.hisp.dhis.helpers.QueryParamsBuilder;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

/**
* @author david mackessy
*/
class DatastoreEntriesTest extends ApiTest {

private RestApiActions datastoreActions;
private RestApiActions sharingActions;
private LoginActions loginActions;
private UserActions userActions;

private static final String NAMESPACE = "football";
private static final String BASIC_USER = "User123";
private String basicUserId = "";
private String userGroupId = "";

@BeforeAll
public void beforeAll() {
datastoreActions = new RestApiActions("dataStore");
sharingActions = new RestApiActions("sharing");
loginActions = new LoginActions();
userActions = new UserActions();
basicUserId = userActions.addUser(BASIC_USER, "Test1234!");

RestApiActions userGroupActions = new RestApiActions("userGroups");
userGroupId = userGroupActions.post("{\"name\":\"basic user group\"}").extractUid();
}

@AfterEach
public void deleteEntries() {
datastoreActions.delete(NAMESPACE).validateStatus(200);
}

@Test
@DisplayName("User can read a datastore entry with default public sharing")
void testDatastoreSharing_DefaultPublicAccess_BasicUser() {
// add entry as admin
loginActions.loginAsAdmin();
String key1 = "arsenal";
datastoreActions.post("/" + NAMESPACE + "/" + key1, newEntry(key1)).validate().statusCode(201);

// make call as basic user and check can see entry
loginActions.loginAsUser(BASIC_USER, "Test1234!");
ApiResponse getResponse =
datastoreActions.get("/" + NAMESPACE + "/" + key1).validateStatus(200);
assertEquals("{\"name\": \"arsenal\", \"league\": \"prem\"}", getResponse.getAsString());
}

@Test
@DisplayName("Superuser can read a datastore entry when public sharing set to none")
void testDatastoreSharing_NoPublicAccess_SuperUser() {
// add entry as admin
loginActions.loginAsAdmin();
String key1 = "arsenal";
datastoreActions.post("/" + NAMESPACE + "/" + key1, newEntry(key1)).validate().statusCode(201);

// get ids of entries
ApiResponse mdResponse1 = datastoreActions.get("/" + NAMESPACE + "/" + key1 + "/metaData");
String uid1 = mdResponse1.extractUid();

// set sharing public access to '--------'
QueryParamsBuilder sharingParams =
new QueryParamsBuilder().add("type", "dataStore").add("id", uid1);
sharingActions.post("", sharingNoPublicAccess(), sharingParams).validateStatus(200);

// make call as superuser and check can see entry
loginActions.loginAsSuperUser();
ApiResponse getResponse =
datastoreActions.get("/" + NAMESPACE + "/" + key1).validateStatus(200);
assertEquals("{\"name\": \"arsenal\", \"league\": \"prem\"}", getResponse.getAsString());
}

@Test
@DisplayName("User can't read a datastore entry when public sharing set to none")
void testDatastoreUserSharing_NoPublicAccess_UserNoAccess() {
// add entry as admin
loginActions.loginAsAdmin();
String key1 = "arsenal";
datastoreActions.post("/" + NAMESPACE + "/" + key1, newEntry(key1)).validate().statusCode(201);

// get id of entry
ApiResponse mdResponse1 = datastoreActions.get("/" + NAMESPACE + "/" + key1 + "/metaData");
String uid1 = mdResponse1.extractUid();

// set sharing public access to '--------'
QueryParamsBuilder sharingParams1 =
new QueryParamsBuilder().add("type", "dataStore").add("id", uid1);
sharingActions.post("", sharingNoPublicAccess(), sharingParams1).validateStatus(200);

// make call as user with no access and check can't see entry
loginActions.loginAsUser(BASIC_USER, "Test1234!");
ApiResponse getResponse =
datastoreActions.get("/" + NAMESPACE + "/" + key1).validateStatus(403);
assertEquals(
"{\"httpStatus\":\"Forbidden\",\"httpStatusCode\":403,\"status\":\"ERROR\",\"message\":\"Access denied for key 'arsenal' in namespace 'football'\"}",
getResponse.getAsString());
}

@Test
@DisplayName(
"User can read a datastore entry when public sharing set to none and user has user sharing access")
void testDatastoreUserSharing_NoPublicAccess_UserHasAccess() {
// add entry as admin
loginActions.loginAsAdmin();
String key1 = "arsenal";
datastoreActions.post("/" + NAMESPACE + "/" + key1, newEntry(key1)).validate().statusCode(201);

// get ids of entry
ApiResponse mdResponse1 = datastoreActions.get("/" + NAMESPACE + "/" + key1 + "/metaData");
String uid1 = mdResponse1.extractUid();

// share entry with user and set public access to '--------'
QueryParamsBuilder params = new QueryParamsBuilder().add("type", "dataStore").add("id", uid1);
sharingActions.post("", sharingUserAccess(basicUserId), params).validateStatus(200);

// make call as user with access and check can see entry
loginActions.loginAsUser(BASIC_USER, "Test1234!");
ApiResponse getResponse = datastoreActions.get("/" + NAMESPACE + "/" + key1);
assertEquals("{\"name\": \"arsenal\", \"league\": \"prem\"}", getResponse.getAsString());
}

@Test
@DisplayName(
"User can read a datastore entry when public sharing set to none and user has user group sharing access")
void testDatastoreUserGroupSharing_NoPublicAccess_UserHasAccess() {
// add entry as admin
loginActions.loginAsAdmin();
String key1 = "arsenal";
datastoreActions.post("/" + NAMESPACE + "/" + key1, newEntry(key1)).validate().statusCode(201);

// get ids of entry
ApiResponse mdResponse1 = datastoreActions.get("/" + NAMESPACE + "/" + key1 + "/metaData");
String uid1 = mdResponse1.extractUid();

// add user to user group
userActions.post(basicUserId + "/userGroups/" + userGroupId, "").validateStatus(200);

// share entries with user group and set public access to '--------'
QueryParamsBuilder params = new QueryParamsBuilder().add("type", "dataStore").add("id", uid1);
sharingActions.post("", sharingUserGroupAccess(userGroupId), params).validateStatus(200);

// make call as user with access and check can see entry
loginActions.loginAsUser(BASIC_USER, "Test1234!");
ApiResponse getResponse =
datastoreActions.get("/" + NAMESPACE + "/" + key1).validateStatus(200);
assertEquals("{\"name\": \"arsenal\", \"league\": \"prem\"}", getResponse.getAsString());
}

@Test
@DisplayName("User can read datastore entry metadata with default public sharing")
void testDatastoreMetadataSharing_DefaultPublicAccess_BasicUser() {
// add entry as admin
loginActions.loginAsAdmin();
String key1 = "arsenal";
datastoreActions.post("/" + NAMESPACE + "/" + key1, newEntry(key1)).validate().statusCode(201);

// make call as user and check can see entry metadata
loginActions.loginAsUser(BASIC_USER, "Test1234!");
ApiResponse getResponse =
datastoreActions.get("/" + NAMESPACE + "/" + key1 + "/metaData").validateStatus(200);
JsonObject createdBy = getResponse.getBody().getAsJsonObject("createdBy");
assertEquals(
"{\"id\":\"PQD6wXJ2r5k\",\"code\":null,\"name\":\"TA Admin\",\"displayName\":\"TA Admin\",\"username\":\"taadmin\"}",
createdBy.toString());
}

@Test
@DisplayName("Superuser can read datastore entry metadata when public sharing set to none")
void testDatastoreMetadataSharing_NoPublicAccess_SuperUser() {
// add entry as admin
loginActions.loginAsAdmin();
String key1 = "arsenal";
datastoreActions.post("/" + NAMESPACE + "/" + key1, newEntry(key1)).validate().statusCode(201);

// get ids of entries
ApiResponse mdResponse1 = datastoreActions.get("/" + NAMESPACE + "/" + key1 + "/metaData");
String uid1 = mdResponse1.extractUid();

// set sharing public access to '--------'
QueryParamsBuilder sharingParams =
new QueryParamsBuilder().add("type", "dataStore").add("id", uid1);
sharingActions.post("", sharingNoPublicAccess(), sharingParams).validateStatus(200);

// make call as superuser and check can see entry metadata
loginActions.loginAsSuperUser();
ApiResponse getResponse =
datastoreActions.get("/" + NAMESPACE + "/" + key1 + "/metaData").validateStatus(200);
JsonObject createdBy = getResponse.getBody().getAsJsonObject("createdBy");
assertEquals(
"{\"id\":\"PQD6wXJ2r5k\",\"code\":null,\"name\":\"TA Admin\",\"displayName\":\"TA Admin\",\"username\":\"taadmin\"}",
createdBy.toString());
}

@Test
@DisplayName("User can't read datastore entry metadata when public sharing set to none")
void testDatastoreMetadataUserSharing_NoPublicAccess_UserNoAccess() {
// add entry as admin
loginActions.loginAsAdmin();
String key1 = "arsenal";
datastoreActions.post("/" + NAMESPACE + "/" + key1, newEntry(key1)).validate().statusCode(201);

// get id of entry
ApiResponse mdResponse1 = datastoreActions.get("/" + NAMESPACE + "/" + key1 + "/metaData");
String uid1 = mdResponse1.extractUid();

// set sharing public access to '--------'
QueryParamsBuilder sharingParams1 =
new QueryParamsBuilder().add("type", "dataStore").add("id", uid1);
sharingActions.post("", sharingNoPublicAccess(), sharingParams1).validateStatus(200);

// make call as basic user with no access and check can't see entry metadata
loginActions.loginAsUser(BASIC_USER, "Test1234!");
ApiResponse getResponse =
datastoreActions.get("/" + NAMESPACE + "/" + key1 + "/metaData").validateStatus(403);
assertEquals(
"{\"httpStatus\":\"Forbidden\",\"httpStatusCode\":403,\"status\":\"ERROR\",\"message\":\"Access denied for key 'arsenal' in namespace 'football'\"}",
getResponse.getAsString());
}

@Test
@DisplayName(
"User can read datastore entry metadata when public sharing set to none and user has user sharing access")
void testDatastoreMetadataUserSharing_NoPublicAccess_UserHasAccess() {
// add entry as admin
loginActions.loginAsAdmin();
String key1 = "arsenal";
datastoreActions.post("/" + NAMESPACE + "/" + key1, newEntry(key1)).validate().statusCode(201);

// get ids of entry
ApiResponse mdResponse1 = datastoreActions.get("/" + NAMESPACE + "/" + key1 + "/metaData");
String uid1 = mdResponse1.extractUid();

// share entry with user and set public access to '--------'
QueryParamsBuilder params = new QueryParamsBuilder().add("type", "dataStore").add("id", uid1);
sharingActions.post("", sharingUserAccess(basicUserId), params).validateStatus(200);

// make call as user with access and check can see entry metadata
loginActions.loginAsUser(BASIC_USER, "Test1234!");
ApiResponse getResponse = datastoreActions.get("/" + NAMESPACE + "/" + key1 + "/metaData");
JsonObject createdBy = getResponse.getBody().getAsJsonObject("createdBy");
assertEquals(
"{\"id\":\"PQD6wXJ2r5k\",\"code\":null,\"name\":\"TA Admin\",\"displayName\":\"TA Admin\",\"username\":\"taadmin\"}",
createdBy.toString());
}

@Test
@DisplayName(
"User can read datastore entry metadata when public sharing set to none and user has user group sharing access")
void testDatastoreMetadataUserGroupSharing_NoPublicAccess_UserHasAccess() {
// add entry as admin
loginActions.loginAsAdmin();
String key1 = "arsenal";
datastoreActions.post("/" + NAMESPACE + "/" + key1, newEntry(key1)).validate().statusCode(201);

// get ids of entry
ApiResponse mdResponse1 = datastoreActions.get("/" + NAMESPACE + "/" + key1 + "/metaData");
String uid1 = mdResponse1.extractUid();

// add user to user group
userActions.post(basicUserId + "/userGroups/" + userGroupId, "").validateStatus(200);

// share entries with user group and set public access to '--------'
QueryParamsBuilder params = new QueryParamsBuilder().add("type", "dataStore").add("id", uid1);
sharingActions.post("", sharingUserGroupAccess(userGroupId), params).validateStatus(200);

// make call as user with access and check can see entry metadata
loginActions.loginAsUser(BASIC_USER, "Test1234!");
ApiResponse getResponse =
datastoreActions.get("/" + NAMESPACE + "/" + key1 + "/metaData").validateStatus(200);
JsonObject createdBy = getResponse.getBody().getAsJsonObject("createdBy");
assertEquals(
"{\"id\":\"PQD6wXJ2r5k\",\"code\":null,\"name\":\"TA Admin\",\"displayName\":\"TA Admin\",\"username\":\"taadmin\"}",
createdBy.toString());
}
}
Loading

0 comments on commit b3e8bf3

Please sign in to comment.