diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java index 7085dc8d1..ca53139c8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java @@ -172,6 +172,15 @@ public class SecurityConfig { // IP allowlist) — a request with no key and no Fineract login still gets nothing, // it just fails inside the handler instead of at this filter. .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clientaccounts/**")).permitAll() + // Tenant menu access — same dual-mode X-Api-Key posture as clientaccounts above + // for the two GETs (TenantMenuAccessApiResource#read still requires an ordinary + // session when no key is sent, same as ClientAllAccountsApiResource does; permitAll + // here only opens Spring Security's outer gate for the key path, it does not skip + // the handler's own check). The PUT has no session fallback inside the handler at + // all, even for a logged-in super user — see TenantMenuAccessApiResource#save. + .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/tenant-menu-access")).permitAll() + .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/tenant-menu-access/tenants")).permitAll() + .requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/tenant-menu-access")).permitAll() // Login OTP — same posture as PAN/GSTIN public verification: provider-credentialed, // no Fineract auth required. .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/generate")).permitAll() diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/TenantMenuAccessApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/TenantMenuAccessApiResource.java new file mode 100644 index 000000000..1bff751e7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/TenantMenuAccessApiResource.java @@ -0,0 +1,214 @@ +/** + * 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.infrastructure.security.apiclient; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.infrastructure.core.service.tenant.TenantDetailsService; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Which top-nav menu sections a tenant is allowed to ever show, regardless of what any role inside + * that tenant grants itself. + * + * {@code HasPermissionDirective} on the frontend already hides a menu item a user's own role lacks -- + * but a role carrying {@code ALL_FUNCTIONS} (a super user, or a role simply misconfigured to hold it, + * as the "Clerk" role was found to in two tenants this session) bypasses that check unconditionally. + * A bank's own admin screen for roles and permissions therefore cannot be trusted as the only gate on + * "does this bank ever get to see the HRM/NPA/Admin section" -- whoever ends up with ALL_FUNCTIONS in + * that tenant, today or after some future role edit, would still see it. This table is the ceiling + * that check now also has to clear. + * + * Reading it accepts either credential (see {@link #read} below) -- a bank's own user needs their own + * tenant's list to render their nav correctly, the same way they already read their own permissions, + * and the platform operator needs to read ANY tenant's current list before toggling it. Writing it + * accepts only the platform's own {@code X-Api-Key} + {@link ApiClientCredentialService} credential -- + * the same mechanism {@code ClientAllAccountsApiResource} uses for third-party callers, but with no + * session fallback at all here. That is deliberate -- a bank's own super user must never be able to + * lift its own ceiling by granting themselves a permission from inside their own tenant, which is + * exactly what a normal {@code ALL_FUNCTIONS} gate would allow. The key is minted once via + * {@code POST /v1/apiclients} by the platform operator and never handed to a bank. + */ +@Path("/v1/tenant-menu-access") +@Component +@Tag(name = "Tenant Menu Access", + description = "Per-tenant cap on which top-nav menu sections can ever be shown, independent of any role's own permissions. Read by any logged-in user of the tenant; written only via a platform-operator API key.") +@RequiredArgsConstructor +public class TenantMenuAccessApiResource { + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final ApiClientCredentialService apiClientCredentialService; + private final TenantDetailsService tenantDetailsService; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + private static final String[] IP_HEADER_CANDIDATES = { "X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP" }; + + // The whole, fixed set of top-nav menu codes this screen knows about -- see 0541 for how this + // list was seeded. A write is only ever a PATCH over this table's existing rows (see save()); a + // code outside this set cannot be created by the API, so a typo in a request body is silently a + // no-op rather than junk data the frontend would then have to guard against. + private static final Set KNOWN_MENU_CODES = Set.of("MENU_INSTITUTION", "SIDEBAR_CFCFRMS", "MENU_TASKS", + "MENU_CASHIER", "MENU_CLERK", "MENU_BRANCH_OPS", "MENU_CLEARING", "MENU_DEPOSITS", "MENU_ADVANCES", + "MENU_LOAN_ORIGINATION", "MENU_SHARE", "MENU_NPA", "MENU_GENERAL_LEDGER", "MENU_SUB_LEDGER", "MENU_ACCOUNTING", + "MENU_CKYC_MGMT", "MENU_INSURANCE", "MENU_DIGITAL", "MENU_HRM", "MENU_ADMIN", "MENU_RBI", "MENU_INVESTMENT", + "MENU_REPORTS", "MENU_OTHERS", "MENU_CONFIG_WIZARD", "MENU_CHEQUEBOOK", "MENU_LETTER_MGMT"); + + /** + * This tenant's own menu-access list, for the tenant named in {@code Fineract-Platform-TenantId}. + * Dual-mode read, same posture as {@code ClientAllAccountsApiResource}: an ordinary logged-in + * user reads their own tenant's list exactly like they already read their own permission list + * (no elevated permission required), or the platform operator reads ANY tenant's list with the + * API key, to know the current state before toggling it on their own control screen. Only the + * write side ({@link #save}) has no session fallback -- reading configuration is not sensitive, + * changing it is. + */ + @GET + @Produces(MediaType.APPLICATION_JSON) + public String read(@HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest) { + if (StringUtils.isNotBlank(apiKey)) { + apiClientCredentialService.authenticate(apiKey, resolveClientIp(servletRequest)); + } else { + context.authenticatedUser(); + } + final List> rows = jdbcTemplate.queryForList("SELECT menu_code, enabled FROM m_tenant_menu_access"); + final JsonArray out = new JsonArray(); + for (final Map row : rows) { + final JsonObject o = new JsonObject(); + o.addProperty("menuCode", (String) row.get("menu_code")); + o.addProperty("enabled", Boolean.TRUE.equals(row.get("enabled"))); + out.add(o); + } + return gson.toJson(out); + } + + /** + * Every tenant configured on this platform, for the operator's own tenant picker -- always the + * master list from the tenant-store database, never the one tenant the caller's own + * {@code Fineract-Platform-TenantId} header happened to resolve to. That header still has to name + * a real tenant for the request to reach this handler at all (see + * {@code TenantAwareBasicAuthenticationFilter}); which one is otherwise irrelevant here. + */ + @GET + @Path("/tenants") + @Produces(MediaType.APPLICATION_JSON) + public String listTenants(@HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest) { + requireApiKey(apiKey, servletRequest); + final JsonArray out = new JsonArray(); + for (final FineractPlatformTenant tenant : tenantDetailsService.findAllTenants()) { + final JsonObject o = new JsonObject(); + o.addProperty("identifier", tenant.getTenantIdentifier()); + o.addProperty("name", tenant.getName()); + out.add(o); + } + return gson.toJson(out); + } + + /** + * Sets which menu codes the tenant named in {@code Fineract-Platform-TenantId} may show, from the + * operator's own control screen. API key only -- see the class comment for why a normal session, + * even a super user's, is never accepted here. + * + * Body: {@code [{"menuCode": "MENU_HRM", "enabled": false}, ...]}. Only codes already in + * {@link #KNOWN_MENU_CODES} are written; anything else in the body is ignored rather than + * rejected, so the operator's screen can always send its full known set without first checking + * this deployment's exact backend version. + */ + @PUT + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public String save(@HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest, + final String body) { + requireApiKey(apiKey, servletRequest); + + final JsonArray entries = gson.fromJson(body, JsonArray.class); + if (entries == null) { + throw apiError(400, "Request body must be a JSON array of {menuCode, enabled}."); + } + final Set touched = new LinkedHashSet<>(); + for (final var el : entries) { + final JsonObject entry = el.getAsJsonObject(); + if (!entry.has("menuCode") || entry.get("menuCode").isJsonNull()) { + continue; + } + final String menuCode = entry.get("menuCode").getAsString(); + if (!KNOWN_MENU_CODES.contains(menuCode)) { + continue; + } + final boolean enabled = entry.has("enabled") && !entry.get("enabled").isJsonNull() && entry.get("enabled").getAsBoolean(); + jdbcTemplate.update("UPDATE m_tenant_menu_access SET enabled = ? WHERE menu_code = ?", enabled, menuCode); + touched.add(menuCode); + } + final JsonObject res = new JsonObject(); + res.addProperty("tenant", ThreadLocalContextUtil.getTenant().getTenantIdentifier()); + res.addProperty("updated", touched.size()); + return gson.toJson(res); + } + + /** No session fallback, unlike every other API-key-capable endpoint in this codebase -- see the class comment. */ + private void requireApiKey(final String apiKey, final HttpServletRequest servletRequest) { + if (StringUtils.isBlank(apiKey)) { + throw apiError(403, "This endpoint is only reachable with a platform operator API key."); + } + apiClientCredentialService.authenticate(apiKey, resolveClientIp(servletRequest)); + } + + private String resolveClientIp(final HttpServletRequest request) { + for (final String header : IP_HEADER_CANDIDATES) { + final String value = request.getHeader(header); + if (StringUtils.isNotBlank(value)) { + return value.split(",")[0].trim(); + } + } + return request.getRemoteAddr(); + } + + private WebApplicationException apiError(final int httpStatus, final String message) { + final JsonObject body = new JsonObject(); + body.addProperty("message", message); + body.addProperty("defaultUserMessage", message); + body.addProperty("httpStatus", httpStatus); + return new WebApplicationException( + Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build()); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 0b6a6606c..e020dea5c 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -67,6 +67,8 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0541_create_tenant_menu_access.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0541_create_tenant_menu_access.xml new file mode 100644 index 000000000..45c7773cc --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0541_create_tenant_menu_access.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_tenant_menu_access + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0542_split_loan_origination_investment_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0542_split_loan_origination_investment_permissions.xml new file mode 100644 index 000000000..991ad7825 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0542_split_loan_origination_investment_permissions.xml @@ -0,0 +1,77 @@ + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'MENU_LOAN_ORIGINATION' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'MENU_INVESTMENT' + + + + + + + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT rp.role_id, p2.id + FROM m_role_permission rp + JOIN m_permission p1 ON p1.id = rp.permission_id AND p1.code = 'MENU_ADVANCES' + JOIN m_permission p2 ON p2.code = 'MENU_LOAN_ORIGINATION' + WHERE NOT EXISTS ( + SELECT 1 FROM m_role_permission existing + WHERE existing.role_id = rp.role_id AND existing.permission_id = p2.id) + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT rp.role_id, p2.id + FROM m_role_permission rp + JOIN m_permission p1 ON p1.id = rp.permission_id AND p1.code = 'MENU_RBI' + JOIN m_permission p2 ON p2.code = 'MENU_INVESTMENT' + WHERE NOT EXISTS ( + SELECT 1 FROM m_role_permission existing + WHERE existing.role_id = rp.role_id AND existing.permission_id = p2.id) + + + + + + + + + SELECT COUNT(*) FROM m_tenant_menu_access WHERE menu_code = 'MENU_LOAN_ORIGINATION' + + + + + +