Skip to content
Open
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 @@ -204,6 +204,19 @@ public FineractEntityRelationData mapRow(final ResultSet rs, @SuppressWarnings("
@Override
public Collection<FineractEntityToEntityMappingData> retrieveEntityToEntityMappings(Long mapId, Long fromId, Long toId) {

if (fromId == 0) {
final String fromEntityTypeSql = "SELECT er.from_entity_type FROM m_entity_relation er WHERE er.id = ?";
Integer fromEntityType = jdbcTemplate.queryForObject(fromEntityTypeSql, Integer.class, mapId);
if (fromEntityType != null && fromEntityType == 1) {
final AppUser currentUser = this.context.authenticatedUser();
final Long userOfficeId = currentUser.getOffice().getId();

EntityToEntityMapper entityToEntityMapper = new EntityToEntityMapper();
String officeScopedSql = entityToEntityMapper.schemaWithOfficeScopedFromId();
return this.jdbcTemplate.query(officeScopedSql, entityToEntityMapper, new Object[] { userOfficeId, mapId, toId, toId });
}
}

EntityToEntityMapper entityToEntityMapper = new EntityToEntityMapper();
String sql = entityToEntityMapper.schema();
final Collection<FineractEntityToEntityMappingData> mapTypes = this.jdbcTemplate.query(sql, entityToEntityMapper,
Expand Down Expand Up @@ -302,6 +315,46 @@ public String schema() {
return ENTITY_TO_ENTITY_SCHEMA;
}

public String schemaWithOfficeScopedFromId() {
StringBuilder str = new StringBuilder("WITH RECURSIVE office_descendants AS ( ");
str.append("SELECT id FROM m_office WHERE id = ? ");
str.append("UNION ALL ");
str.append("SELECT o.id FROM m_office o JOIN office_descendants d ON o.parent_id = d.id ");
str.append(") ");
str.append("select eem.id as mapId, ");
str.append("eem.rel_id as relId, ");
str.append("eem.from_id as from_id, ");
str.append("eem.to_id as to_id, ");
str.append("eem.start_date as startDate, ");
str.append("eem.end_date as endDate, ");
str.append("case er.code_name ");
str.append("when 'office_access_to_loan_products' then o.name ");
str.append("when 'office_access_to_savings_products' then o.name ");
str.append("when 'office_access_to_fees/charges' then o.name ");
str.append("when 'role_access_to_loan_products' then r.name ");
str.append("when 'role_access_to_savings_products' then r.name ");
str.append("end as from_name, ");
str.append("case er.code_name ");
str.append("when 'office_access_to_loan_products' then lp.name ");
str.append("when 'office_access_to_savings_products' then sp.name ");
str.append("when 'office_access_to_fees/charges' then charge.name ");
str.append("when 'role_access_to_loan_products' then lp.name ");
str.append("when 'role_access_to_savings_products' then sp.name ");
str.append("end as to_name, ");
str.append("er.code_name ");
str.append("from m_entity_to_entity_mapping eem ");
str.append("join m_entity_relation er on eem.rel_id = er.id ");
str.append("left join m_office o on er.from_entity_type = 1 and eem.from_id = o.id ");
str.append("left join m_role r on er.from_entity_type = 5 and eem.from_id = r.id ");
str.append("left join m_product_loan lp on er.to_entity_type = 2 and eem.to_id = lp.id ");
str.append("left join m_savings_product sp on er.to_entity_type = 3 and eem.to_id = sp.id ");
str.append("left join m_charge charge on er.to_entity_type = 4 and eem.to_id = charge.id ");
str.append("where er.id = ? ");
str.append("and eem.from_id IN (SELECT id FROM office_descendants) ");
str.append("and ( ? = 0 or to_id = ? ) ");
return str.toString();
}

@Override
public FineractEntityToEntityMappingData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum)
throws SQLException {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.integrationtests;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

import io.restassured.builder.RequestSpecBuilder;
import io.restassured.builder.ResponseSpecBuilder;
import io.restassured.http.ContentType;
import io.restassured.specification.RequestSpecification;
import io.restassured.specification.ResponseSpecification;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.fineract.client.models.PostOfficesResponse;
import org.apache.fineract.integrationtests.common.OfficeHelper;
import org.apache.fineract.integrationtests.common.Utils;
import org.apache.fineract.integrationtests.common.loans.LoanProductTestBuilder;
import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper;
import org.apache.fineract.integrationtests.useradministration.roles.RolesHelper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

/**
* Integration tests for the EntityToEntityMapping office-scoping security fix.
*
* When fromId=0 (meaning "all offices") is passed to the GET /entitytoentitymapping/{mapId}/{fromId}/{toId} endpoint,
* results must be scoped to the authenticated user's office hierarchy rather than returning every office's mappings.
*/
public class EntityToEntityMappingIntegrationTest {

private static final String ENTITY_MAPPING_BASE_URL = "/fineract-provider/api/v1/entitytoentitymapping";
private static final String CREATE_USER_URL = "/fineract-provider/api/v1/users?" + Utils.TENANT_IDENTIFIER;
private static final String OFFICE_ACCESS_TO_LOAN_PRODUCTS = "office_access_to_loan_products";

private RequestSpecification adminRequestSpec;
private ResponseSpecification responseSpec;

@BeforeEach
public void setUp() {
Utils.initializeRESTAssured();
adminRequestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build();
adminRequestSpec.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey());
responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build();
}

@Test
public void testOfficeUserSeesOnlyOwnOfficeMappingsWhenFromIdIsZero() {
OfficeHelper officeHelper = new OfficeHelper();
PostOfficesResponse officeA = officeHelper.createOffice(LocalDate.of(2024, 1, 1));
PostOfficesResponse officeB = officeHelper.createOffice(LocalDate.of(2024, 1, 1));
assertNotNull(officeA.getOfficeId());
assertNotNull(officeB.getOfficeId());

LoanTransactionHelper loanHelper = new LoanTransactionHelper(adminRequestSpec, responseSpec);
Integer loanProductId = loanHelper.getLoanProductId(new LoanProductTestBuilder().build());
assertNotNull(loanProductId);

Long relId = getOfficeToLoanProductRelationId();
assertNotNull(relId, "office_access_to_loan_products relation must exist in the database");

createEntityMapping(relId, officeA.getOfficeId(), loanProductId.longValue());
createEntityMapping(relId, officeB.getOfficeId(), loanProductId.longValue());

Integer roleId = RolesHelper.createRole(adminRequestSpec, responseSpec);
grantReadFineractEntityPermission(roleId);
String username = Utils.uniqueRandomStringGenerator("OfficeAUser", 6);
String password = "Test@1234#X";
createUserAtOffice(username, password, roleId, officeA.getOfficeId());

RequestSpecification officeARequestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build();
officeARequestSpec.header("Authorization",
"Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey(username, password));

// fromId=0 means "all offices" — the fix scopes this to the user's office hierarchy
String url = ENTITY_MAPPING_BASE_URL + "/" + relId + "/0/0?" + Utils.TENANT_IDENTIFIER;
List<Map<String, Object>> mappings = Utils.performServerGet(officeARequestSpec, responseSpec, url, "$");

assertNotNull(mappings);
long officeAMappingCount = mappings.stream().filter(m -> officeA.getOfficeId().equals(toLong(m.get("fromId")))).count();
long officeBMappingCount = mappings.stream().filter(m -> officeB.getOfficeId().equals(toLong(m.get("fromId")))).count();
assertEquals(1, officeAMappingCount, "Office A user should see Office A's mapping");
assertEquals(0, officeBMappingCount, "Office A user must NOT see Office B's mapping");
}

@Test
public void testHeadOfficeUserSeesAllDescendantOfficeMappingsWhenFromIdIsZero() {
OfficeHelper officeHelper = new OfficeHelper();
PostOfficesResponse officeA = officeHelper.createOffice(LocalDate.of(2024, 1, 1));
PostOfficesResponse officeB = officeHelper.createOffice(LocalDate.of(2024, 1, 1));

LoanTransactionHelper loanHelper = new LoanTransactionHelper(adminRequestSpec, responseSpec);
Integer loanProductId = loanHelper.getLoanProductId(new LoanProductTestBuilder().build());

Long relId = getOfficeToLoanProductRelationId();
assertNotNull(relId, "office_access_to_loan_products relation must exist in the database");

createEntityMapping(relId, officeA.getOfficeId(), loanProductId.longValue());
createEntityMapping(relId, officeB.getOfficeId(), loanProductId.longValue());

// The superadmin belongs to Head Office, which is the parent of both Office A and B
String url = ENTITY_MAPPING_BASE_URL + "/" + relId + "/0/0?" + Utils.TENANT_IDENTIFIER;
List<Map<String, Object>> mappings = Utils.performServerGet(adminRequestSpec, responseSpec, url, "$");

assertNotNull(mappings);
long officeAMappingCount = mappings.stream().filter(m -> officeA.getOfficeId().equals(toLong(m.get("fromId")))).count();
long officeBMappingCount = mappings.stream().filter(m -> officeB.getOfficeId().equals(toLong(m.get("fromId")))).count();
assertEquals(1, officeAMappingCount, "Head office user should see Office A's mapping");
assertEquals(1, officeBMappingCount, "Head office user should see Office B's mapping");
}

private Long getOfficeToLoanProductRelationId() {
List<Map<String, Object>> mappingTypes = Utils.performServerGet(adminRequestSpec, responseSpec,
ENTITY_MAPPING_BASE_URL + "?" + Utils.TENANT_IDENTIFIER, "$");
if (mappingTypes == null) {
return null;
}
return mappingTypes.stream().filter(t -> OFFICE_ACCESS_TO_LOAN_PRODUCTS.equals(t.get("mappingTypes"))).map(t -> toLong(t.get("id")))
.findFirst().orElse(null);
}

private void createEntityMapping(Long relId, Long fromId, Long toId) {
String body = "{\"fromId\":" + fromId + ",\"toId\":" + toId
+ ",\"startDate\":\"01 January 2024\",\"locale\":\"en\",\"dateFormat\":\"dd MMMM yyyy\"}";
Utils.performServerPost(adminRequestSpec, responseSpec, ENTITY_MAPPING_BASE_URL + "/" + relId + "?" + Utils.TENANT_IDENTIFIER, body,
"resourceId");
}

private void grantReadFineractEntityPermission(Integer roleId) {
Map<String, Boolean> permissions = new HashMap<>();
permissions.put("READ_FINERACTENTITY", true);
RolesHelper.addPermissionsToRole(adminRequestSpec, responseSpec, roleId, permissions);
}

private void createUserAtOffice(String username, String password, Integer roleId, Long officeId) {
String body = "{\"username\":\"" + username + "\",\"firstname\":\"Test\",\"lastname\":\"User\","
+ "\"email\":\"test@mifos.org\",\"officeId\":" + officeId + ",\"roles\":[" + roleId
+ "],\"sendPasswordToEmail\":false,\"password\":\"" + password + "\",\"repeatPassword\":\"" + password + "\"}";
Utils.performServerPost(adminRequestSpec, responseSpec, CREATE_USER_URL, body, "resourceId");
}

private static Long toLong(Object value) {
if (value == null) {
return null;
}
return ((Number) value).longValue();
}
}
Loading