Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s

# Conflicts:
#	fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml
This commit is contained in:
2026-09-14 11:46:43 +05:30
5 changed files with 366 additions and 0 deletions
@@ -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()
@@ -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<String> 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<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT menu_code, enabled FROM m_tenant_menu_access");
final JsonArray out = new JsonArray();
for (final Map<String, Object> 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<String> 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());
}
}
@@ -67,6 +67,8 @@
<include file="parts/0538_add_branch_pincode.xml" relativeToChangelogFile="true"/>
<include file="parts/0539_seed_cash_exchange_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0540_unique_active_cash_settlement_per_cashier_day.xml" relativeToChangelogFile="true"/>
<include file="parts/0541_create_tenant_menu_access.xml" relativeToChangelogFile="true"/>
<include file="parts/0542_split_loan_origination_investment_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0546_aml_rule_direction_and_cash_scope.xml" relativeToChangelogFile="true"/>
<include file="parts/0547_aml_rule_subject_scope.xml" relativeToChangelogFile="true"/>
<include file="parts/0548_sponsor_dd_revoke_request.xml" relativeToChangelogFile="true"/>
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- A bank's own role/permission screen cannot be trusted to keep a nav item hidden: any role
holding ALL_FUNCTIONS (a super user, or a role misconfigured to carry it, as Clerk was found
to be in two tenants this session) bypasses every MENU_.../SIDEBAR_... permission check on the
frontend unconditionally. This table is the hard ceiling that check now also has to clear,
so an operator onboarding a new bank can cap which top-nav sections it can EVER show, no
matter what a future role in that bank's own tenant grants itself. Deliberately not editable
from inside the tenant it governs (see TenantMenuAccessApiResource) - the whole point is that
a bank's own admin cannot lift this ceiling on themselves.
One row per top-nav menu code, all enabled by default so every tenant that already exists
keeps behaving exactly as it does today until an operator deliberately narrows one. -->
<changeSet id="0541-1" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_tenant_menu_access" />
</not>
</preConditions>
<createTable tableName="m_tenant_menu_access">
<column name="menu_code" type="VARCHAR(64)">
<constraints primaryKey="true" nullable="false" />
</column>
<column name="enabled" type="BOOLEAN" defaultValueBoolean="true">
<constraints nullable="false" />
</column>
</createTable>
</changeSet>
<changeSet id="0541-2" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_tenant_menu_access</sqlCheck>
</preConditions>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_INSTITUTION" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="SIDEBAR_CFCFRMS" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_TASKS" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_CASHIER" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_CLERK" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_BRANCH_OPS" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_CLEARING" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_DEPOSITS" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_ADVANCES" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_SHARE" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_NPA" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_GENERAL_LEDGER" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_SUB_LEDGER" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_ACCOUNTING" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_CKYC_MGMT" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_INSURANCE" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_DIGITAL" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_HRM" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_ADMIN" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_RBI" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_REPORTS" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_OTHERS" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_CONFIG_WIZARD" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_CHEQUEBOOK" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_LETTER_MGMT" /></insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Two of the 27 top-nav items were sharing a permission code with their neighbour:
Loan Origination rode on MENU_ADVANCES (Advances' own code) and Investment rode on
MENU_RBI (RBI's own code), so neither could be shown or hidden independently, on a
role or on the new per-tenant menu-access ceiling (0541) alike. Splitting them into their
own codes here, and granting the new code to every role that already had the shared one,
so this migration changes nothing about who sees what today. It only makes the two
pairs independently controllable from this point on. -->
<changeSet id="0542-1" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'MENU_LOAN_ORIGINATION'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="heading_menu" />
<column name="code" value="MENU_LOAN_ORIGINATION" />
<column name="entity_name" value="MENU" />
<column name="action_name" value="LOAN_ORIGINATION" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet id="0542-2" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'MENU_INVESTMENT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="heading_menu" />
<column name="code" value="MENU_INVESTMENT" />
<column name="entity_name" value="MENU" />
<column name="action_name" value="INVESTMENT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<!-- Preserve today's visibility: any role that already sees Advances also keeps seeing
Loan Origination, and any role that already sees RBI also keeps seeing Investment. -->
<changeSet id="0542-3" author="cbs-nitesh" runOnChange="true">
<sql>
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)
</sql>
<sql>
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)
</sql>
</changeSet>
<!-- Extend the per-tenant menu-access ceiling (0541) to the two new codes, enabled by default
so nothing already deployed there changes either. -->
<changeSet id="0542-4" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_tenant_menu_access" />
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_tenant_menu_access WHERE menu_code = 'MENU_LOAN_ORIGINATION'</sqlCheck>
</and>
</preConditions>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_LOAN_ORIGINATION" /></insert>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_INVESTMENT" /></insert>
</changeSet>
</databaseChangeLog>