From c20a7d072805e9f2f91e3d52500009573ec8fa57 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 11 Sep 2026 15:07:13 +0530 Subject: [PATCH] feat(security): tenant-level nav menu access control, independent of role permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-tenant ceiling on which of the 27 top-nav sections a bank can ever show, closing a real gap the "Clerk" role's ALL_FUNCTIONS misconfiguration exposed this session: HasPermissionDirective already hid a menu a user's own role lacked, but any role carrying ALL_FUNCTIONS bypassed that check unconditionally, so a bank's own super user (or a role accidentally granted ALL_FUNCTIONS) could always see everything regardless of intent. New table m_tenant_menu_access (0541), one row per menu code, seeded enabled for every existing tenant so nothing already deployed changes behaviour. New TenantMenuAccessApiResource: - GET /v1/tenant-menu-access — dual-mode (session or X-Api-Key), same posture as ClientAllAccountsApiResource. A tenant's own users read their own list to render their nav; the platform operator reads any tenant's list with the key, to know current state before toggling it. - GET /v1/tenant-menu-access/tenants — API key only, the master tenant list for the operator's own tenant picker. - PUT /v1/tenant-menu-access — API key only, no session fallback at all, even for a super user. This is the actual security boundary: a bank's own admin has no path to lift its own ceiling, because there is nothing inside their own tenant that can satisfy this endpoint's auth requirement. 0542 splits two nav items that had been sharing a permission code with their neighbour — Loan Origination rode on MENU_ADVANCES, Investment rode on MENU_RBI — into their own MENU_LOAN_ORIGINATION / MENU_INVESTMENT codes, so all 27 top-nav items are now independently controllable. Every role that already held the shared code is granted the new one in the same migration, so this changes nothing about who sees what today. SecurityConfig: permitAll for the two API-key-only paths, same posture as the existing clientaccounts carve-out — the handler itself is what actually authenticates the caller, not Spring Security's outer gate. --- .../core/config/SecurityConfig.java | 9 + .../TenantMenuAccessApiResource.java | 214 ++++++++++++++++++ .../tenant/final-changelog-tenant.xml | 2 + .../parts/0541_create_tenant_menu_access.xml | 64 ++++++ ...oan_origination_investment_permissions.xml | 77 +++++++ 5 files changed, 366 insertions(+) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/TenantMenuAccessApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0541_create_tenant_menu_access.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0542_split_loan_origination_investment_permissions.xml 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 6b737c847..15554ba53 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,4 +67,6 @@ + + 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' + + + + + +