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 <noreply@anthropic.com>
This commit is contained in:
+5
-3
@@ -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
-14
@@ -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
|
||||
|
||||
+83
@@ -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<String, Object> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user