From e7417d16a6a52365dca95047011d41df0abbb972 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 28 Sep 2026 18:35:11 +0530 Subject: [PATCH 1/3] chore: renumber migration 0585 to 0587 ahead of feature/bugfix merge feature/bugfix independently used 0585/0586 for unrelated migrations (loan COB catch-up, system module checker permissions), colliding with this session's own 0585 file. Renumbered to the first free slot after theirs so both sets of changesets coexist without ambiguity; content and semantics are unchanged, only the file name and changeset ids moved. Co-Authored-By: Claude Sonnet 5 --- .../resources/db/changelog/tenant/final-changelog-tenant.xml | 2 +- ...=> 0587_fix_current_product_od_scheme_pending_columns.xml} | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename fineract-provider/src/main/resources/db/changelog/tenant/parts/{0585_fix_current_product_od_scheme_pending_columns.xml => 0587_fix_current_product_od_scheme_pending_columns.xml} (96%) 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 bbb450357..7727710c9 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 @@ -136,5 +136,5 @@ - + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0585_fix_current_product_od_scheme_pending_columns.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0587_fix_current_product_od_scheme_pending_columns.xml similarity index 96% rename from fineract-provider/src/main/resources/db/changelog/tenant/parts/0585_fix_current_product_od_scheme_pending_columns.xml rename to fineract-provider/src/main/resources/db/changelog/tenant/parts/0587_fix_current_product_od_scheme_pending_columns.xml index 071cc2974..5e2372200 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0585_fix_current_product_od_scheme_pending_columns.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0587_fix_current_product_od_scheme_pending_columns.xml @@ -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"> - + @@ -46,7 +46,7 @@ - + From 1408bddf58215ae6e340548260d7263eb56ea561 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 29 Sep 2026 15:36:18 +0530 Subject: [PATCH 2/3] feat(security): auto-mint the first platform-operator API key on deploy, disable minting one over HTTP A fresh deployment's m_api_client table starts empty, and reaching #/platform-admin/menu-access requires a valid X-Api-Key -- with no key, every tenant 401s identically (this is by design, not tenant-specific). Add DefaultApiClientBootstrapper: an ApplicationRunner that mints exactly one random key on first startup (guarded by "table still empty", so later restarts are a no-op) and logs the raw value once to the server's own application log -- never to the database, never over HTTP. Disable ApiClientAdminApiResource's POST /v1/apiclients entirely: it only required ALL_FUNCTIONS, a per-tenant role grant, so any bank's own super user (or a misconfigured role, as "Clerk" was found to carry it in two tenants this session) could mint themselves a platform-wide operator key and bypass the exact ceiling TenantMenuAccessApiResource exists to enforce. New keys past the first are now a direct m_api_client insert only. Co-Authored-By: Claude Sonnet 5 --- .../core/config/SecurityConfig.java | 8 +- .../apiclient/ApiClientAdminApiResource.java | 34 ++++---- .../DefaultApiClientBootstrapper.java | 83 +++++++++++++++++++ 3 files changed, 108 insertions(+), 17 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/DefaultApiClientBootstrapper.java 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 4e739e895..73e2f73f4 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 @@ -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, diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientAdminApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientAdminApiResource.java index 0c469621a..a6bca7aa2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientAdminApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientAdminApiResource.java @@ -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 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 diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/DefaultApiClientBootstrapper.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/DefaultApiClientBootstrapper.java new file mode 100644 index 000000000..076a56554 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/DefaultApiClientBootstrapper.java @@ -0,0 +1,83 @@ +/** + * 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 java.util.Map; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +/** + * Mints the very first platform-operator API key on a brand-new deployment, once, at startup -- the + * counterpart to {@link ApiClientAdminApiResource} deliberately disabling {@code POST /v1/apiclients}. + * With that HTTP path closed for security (see that class's javadoc), a fresh {@code m_api_client} + * table with zero rows would otherwise leave a first-time deployer with no way at all to reach + * {@code #/platform-admin/menu-access} except hand-writing SQL themselves -- this closes that gap + * without reopening the escalation path the disabled endpoint existed to close. + * + * Runs as an {@link ApplicationRunner}, which Spring Boot only invokes after the context has fully + * refreshed -- i.e. strictly after {@code TenantDatabaseUpgradeService} (an {@code InitializingBean}) + * has already created {@code m_api_client} via migration 0011. The guard is simply "does the table have + * any row yet": on every startup after the first, this is a no-op, so it never mints a second key + * behind anyone's back. + * + * The raw key is deliberately never written to the database (only its hash is, same as any other key) + * and never returned over HTTP -- it is logged ONCE, here, to this server's own application log. That + * is a real security boundary, not a formality: reading it requires access to the server's own log + * output, which is a materially different (and much smaller) trust boundary than "reachable over the + * network," which is exactly why the HTTP minting path was closed instead of left open. + */ +@Component +@RequiredArgsConstructor +public class DefaultApiClientBootstrapper implements ApplicationRunner { + + private static final Logger LOG = LoggerFactory.getLogger(DefaultApiClientBootstrapper.class); + + private final ApiClientCredentialService credentialService; + + @Override + public void run(final ApplicationArguments args) { + if (!credentialService.listClients().isEmpty()) { + return; + } + final Map created = credentialService.createClient("Default Platform Operator (auto-created on first startup)", + null, null); + final String rawKey = (String) created.get("apiKey"); + + LOG.warn(""" + + ================================================================================ + CBSNexora: first-run platform operator API key created. + + This key unlocks #/platform-admin/menu-access on this deployment. It is stored + only as a hash in m_api_client -- this is the ONLY place the raw value is ever + shown. Copy it now; it cannot be recovered later, only reissued as a new key. + + API Key: {} + + Minting a replacement over HTTP is deliberately disabled (see + ApiClientAdminApiResource) -- a further key can only be inserted directly into + m_api_client in the tenant-store database, the same way this one was. + ================================================================================ + """, rawKey); + } +} From 9436eab1298da252ebe6f59cefdc1b2d3f2c5ea9 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 29 Sep 2026 17:01:07 +0530 Subject: [PATCH 3/3] feat(security): seed a fixed default platform API key via migration instead of a random one at runtime Replaces the previous DefaultApiClientBootstrapper (ApplicationRunner minting a random key on first startup) with a plain Liquibase seed on the tenant-store database: one fixed, well-known api_key_hash row, inserted once via migration 0012, same on every deployment and every tenant -- exactly like this fork already seeds default codes/permissions, rather than generating a secret at runtime. The trade-off is explicit in the migration's own comment: a fixed key checked into source is a bootstrap convenience, not a secret, and any deployment beyond local/dev must rotate it (insert a new hash, deactivate this row) before going live -- rotation is a direct m_api_client write, same as the seed itself, since POST /v1/apiclients stays disabled. Co-Authored-By: Claude Sonnet 5 --- .../DefaultApiClientBootstrapper.java | 83 ------------------- .../tenant-store/changelog-tenant-store.xml | 1 + .../0012_seed_default_platform_api_client.xml | 62 ++++++++++++++ 3 files changed, 63 insertions(+), 83 deletions(-) delete mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/DefaultApiClientBootstrapper.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/DefaultApiClientBootstrapper.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/DefaultApiClientBootstrapper.java deleted file mode 100644 index 076a56554..000000000 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/DefaultApiClientBootstrapper.java +++ /dev/null @@ -1,83 +0,0 @@ -/** - * 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 java.util.Map; -import lombok.RequiredArgsConstructor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.stereotype.Component; - -/** - * Mints the very first platform-operator API key on a brand-new deployment, once, at startup -- the - * counterpart to {@link ApiClientAdminApiResource} deliberately disabling {@code POST /v1/apiclients}. - * With that HTTP path closed for security (see that class's javadoc), a fresh {@code m_api_client} - * table with zero rows would otherwise leave a first-time deployer with no way at all to reach - * {@code #/platform-admin/menu-access} except hand-writing SQL themselves -- this closes that gap - * without reopening the escalation path the disabled endpoint existed to close. - * - * Runs as an {@link ApplicationRunner}, which Spring Boot only invokes after the context has fully - * refreshed -- i.e. strictly after {@code TenantDatabaseUpgradeService} (an {@code InitializingBean}) - * has already created {@code m_api_client} via migration 0011. The guard is simply "does the table have - * any row yet": on every startup after the first, this is a no-op, so it never mints a second key - * behind anyone's back. - * - * The raw key is deliberately never written to the database (only its hash is, same as any other key) - * and never returned over HTTP -- it is logged ONCE, here, to this server's own application log. That - * is a real security boundary, not a formality: reading it requires access to the server's own log - * output, which is a materially different (and much smaller) trust boundary than "reachable over the - * network," which is exactly why the HTTP minting path was closed instead of left open. - */ -@Component -@RequiredArgsConstructor -public class DefaultApiClientBootstrapper implements ApplicationRunner { - - private static final Logger LOG = LoggerFactory.getLogger(DefaultApiClientBootstrapper.class); - - private final ApiClientCredentialService credentialService; - - @Override - public void run(final ApplicationArguments args) { - if (!credentialService.listClients().isEmpty()) { - return; - } - final Map created = credentialService.createClient("Default Platform Operator (auto-created on first startup)", - null, null); - final String rawKey = (String) created.get("apiKey"); - - LOG.warn(""" - - ================================================================================ - CBSNexora: first-run platform operator API key created. - - This key unlocks #/platform-admin/menu-access on this deployment. It is stored - only as a hash in m_api_client -- this is the ONLY place the raw value is ever - shown. Copy it now; it cannot be recovered later, only reissued as a new key. - - API Key: {} - - Minting a replacement over HTTP is deliberately disabled (see - ApiClientAdminApiResource) -- a further key can only be inserted directly into - m_api_client in the tenant-store database, the same way this one was. - ================================================================================ - """, rawKey); - } -} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml b/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml index 852255252..0c4578281 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml @@ -32,4 +32,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml b/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml new file mode 100644 index 000000000..a75be07a2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + SELECT COUNT(*) FROM m_api_client + WHERE api_key_hash = '9d6d4dfa7a1953e33e0acde4fd775aad11689ec540340c7224796447118a3edd' + + + + + + + + + + + + + +