diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/entityaccess/service/FineractEntityAccessReadServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/entityaccess/service/FineractEntityAccessReadServiceImpl.java index 767a768e2f1..8db955e38ca 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/entityaccess/service/FineractEntityAccessReadServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/entityaccess/service/FineractEntityAccessReadServiceImpl.java @@ -204,6 +204,19 @@ public FineractEntityRelationData mapRow(final ResultSet rs, @SuppressWarnings(" @Override public Collection 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 mapTypes = this.jdbcTemplate.query(sql, entityToEntityMapper, @@ -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 { diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/EntityToEntityMappingIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/EntityToEntityMappingIntegrationTest.java new file mode 100644 index 00000000000..511f7c3f9d7 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/EntityToEntityMappingIntegrationTest.java @@ -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> 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> 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> 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 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(); + } +}