Merge dev-latest into feature/bugfix

This commit is contained in:
2026-09-30 16:47:41 +05:30
6 changed files with 91 additions and 23 deletions
@@ -165,9 +165,11 @@ public class SecurityConfig {
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
// Client account summary, third-party callers — a partner integration calls this
// with an X-Api-Key header instead of a Fineract username/password (issued via
// POST /v1/apiclients, super-user only). Same posture as IMPS/NPCI above: the
// outer gate stays open for this one narrow GET path, and
// with an X-Api-Key header instead of a Fineract username/password. The key itself
// is issued by inserting directly into m_api_client in the tenant-store database
// (see ApiClientAdminApiResource — its POST /v1/apiclients is deliberately disabled,
// since any bank's own super user reaching it could mint a platform-wide key). Same
// posture as IMPS/NPCI above: the outer gate stays open for this one narrow GET path, and
// ClientAllAccountsApiResource#retrieveByClientNo is what actually authenticates
// the caller (ApiClientCredentialService checks the key's hash, active flag and
// IP allowlist) — a request with no key and no Fineract login still gets nothing,
@@ -20,7 +20,6 @@ package org.apache.fineract.infrastructure.security.apiclient;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
@@ -35,17 +34,26 @@ import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
/**
* Admin-only management of the third-party API keys {@link ApiClientCredentialService} authenticates.
* This resource itself requires a normal Fineract login, same as everything else -- ordinary
* username/password, restricted to a super user (ALL_FUNCTIONS). Only the endpoints that use a minted
* key to skip login (e.g. GET /v1/clientaccounts) are the ones exempted in SecurityConfig; issuing that
* key in the first place is not something to hand to just anyone.
* List/deactivate/reactivate still require a normal Fineract login restricted to a super user
* (ALL_FUNCTIONS) -- but minting a NEW key ({@link #create}) is disabled here entirely, on purpose.
*
* {@code ALL_FUNCTIONS} is a per-TENANT role grant -- and {@code TenantMenuAccessApiResource}'s whole
* point is that a bank's own super user (or a role simply misconfigured to carry ALL_FUNCTIONS, as the
* "Clerk" role was found to in two tenants this session) must never be able to lift its own menu-access
* ceiling. If this endpoint minted keys for any ALL_FUNCTIONS holder, that same bank super user could
* call {@code POST /v1/apiclients} and hand themselves a brand-new platform-operator credential -- one
* that authenticates against every tenant, not just their own -- completely bypassing the ceiling this
* key mechanism exists to enforce. A super user being able to read/deactivate the client list is fine
* (visibility and revocation are not privilege escalation); being able to mint a fresh one is exactly
* the escalation path being closed. New keys are issued the same way this fix issued the first one: a
* direct insert into {@code m_api_client} (see {@link ApiClientCredentialService#createClient}), by
* whoever already has direct database access -- never over HTTP.
*/
@Path("/v1/apiclients")
@Component
@@ -55,20 +63,18 @@ public class ApiClientAdminApiResource {
private final PlatformSecurityContext context;
private final ApiClientCredentialService credentialService;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new GsonBuilder().serializeNulls().create();
/** Disabled -- see the class comment. Minting a key is a direct-database operation only. */
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String create(final String body) {
final AppUser user = requireSuperUser();
final JsonElement root = fromJsonHelper.parse(body);
final String clientName = fromJsonHelper.extractStringNamed("clientName", root);
final String allowedIps = fromJsonHelper.extractStringNamed("allowedIps", root);
final Map<String, Object> result = credentialService.createClient(clientName, allowedIps, user.getId());
return gson.toJson(result);
requireSuperUser();
throw new WebApplicationException(Response.status(403)
.entity("{\"message\":\"Minting a new platform API key over HTTP is disabled. "
+ "Insert directly into m_api_client in the tenant-store database instead.\"}")
.type(MediaType.APPLICATION_JSON).build());
}
@GET
@@ -32,4 +32,5 @@
<include file="parts/0009_set_and_encrypt_ro_if_not_exists.xml" relativeToChangelogFile="true"/>
<include file="parts/0010_set_datetime_precision.xml" relativeToChangelogFile="true"/>
<include file="parts/0011_create_api_client.xml" relativeToChangelogFile="true"/>
<include file="parts/0012_seed_default_platform_api_client.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<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">
<!-- Seeds ONE fixed, well-known platform-operator API key on every deployment, so
#/platform-admin/menu-access works out of the box the first time the app is stood up,
without anyone needing shell/DB access to mint the first key by hand. It is deliberately
the SAME raw value on every deployment/tenant (requested behaviour) rather than a random
one generated per install -- the trade-off, on purpose: this value is visible to anyone who
can read this source file, exactly like a default admin password shipped in a seed script.
It is a bootstrap convenience, not a secret. Rotate it for any deployment that is not purely
local/dev: insert a new row with a real random key's hash (see ApiClientCredentialService's
hashing) and either deactivate or DELETE this row -- POST /v1/apiclients is intentionally
disabled (see ApiClientAdminApiResource), so rotation is a direct m_api_client write, same
as this seed.
Raw key (only shown here, since it is fixed and not a secret to rotate away from):
cbsx_default_platform_operator_key_2026
api_key_hash below is SHA-256 of that exact string, hex-encoded, lowercase -- matching
ApiClientCredentialService#sha256Hex. -->
<changeSet id="0012_seed_default_platform_api_client" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_api_client"/>
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_api_client
WHERE api_key_hash = '9d6d4dfa7a1953e33e0acde4fd775aad11689ec540340c7224796447118a3edd'
</sqlCheck>
</and>
</preConditions>
<insert tableName="m_api_client">
<column name="client_name" value="Default Platform Operator (rotate before real production use)"/>
<column name="api_key_hash" value="9d6d4dfa7a1953e33e0acde4fd775aad11689ec540340c7224796447118a3edd"/>
<column name="api_key_prefix" value="cbsx_default"/>
<column name="allowed_ips"/>
<column name="created_by"/>
<column name="created_by_tenant" value="ALL"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -145,7 +145,6 @@
<include file="parts/0583_save_rbi_form_a_return_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0584_form9_slr_assets.xml" relativeToChangelogFile="true"/>
<include file="parts/0582_create_tds_config.xml" relativeToChangelogFile="true"/>
<<<<<<< HEAD
<!-- Our local 0575 Loan Originator checker-permission migration collided with dev-latest's own
0575_rbi_reserve_custom_roles.xml once merged (it was only stashed/uncommitted locally, so it
never appeared on either side of this merge as a real conflict - the collision only became
@@ -156,7 +155,5 @@
<include file="parts/0584_add_manage_survey_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0585_add_loan_cob_catchup_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0586_add_system_module_checker_permissions.xml" relativeToChangelogFile="true"/>
=======
<include file="parts/0585_fix_current_product_od_scheme_pending_columns.xml" relativeToChangelogFile="true"/>
>>>>>>> dev-latest
<include file="parts/0587_fix_current_product_od_scheme_pending_columns.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -31,7 +31,7 @@
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">
<changeSet author="CBS" id="0585-1-fix-saving-product-temp-od-min-limit">
<changeSet author="CBS" id="0587-1-fix-saving-product-temp-od-min-limit">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_saving_product_temp"/>
@@ -46,7 +46,7 @@
</addColumn>
</changeSet>
<changeSet author="CBS" id="0585-2-fix-saving-product-temp-od-excess-drawing">
<changeSet author="CBS" id="0587-2-fix-saving-product-temp-od-excess-drawing">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_saving_product_temp"/>