From 1408bddf58215ae6e340548260d7263eb56ea561 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 29 Sep 2026 15:36:18 +0530 Subject: [PATCH] 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); + } +}