feat(security): add cross-tenant API key auth for third-party callers
Endpoints that need to be reachable by a third-party integration without a Fineract username/password used to have no supported way to do that other than the heavyweight IMPS/NPCI pattern (RSA-signed JWT envelopes, built for one specific bank-switch protocol). This adds a general-purpose one: mint an X-Api-Key credential, hand it to the partner, done. New table m_api_client lives in the tenant-STORE database (fineract_tenants), not any one bank's own tenant database. That is deliberate: a key identifies a CALLER, independent of which bank's data a given request asks for, so one minted key keeps working against every tenant that exists now and every one provisioned later -- a per-tenant copy would need a manual re-insert on every new bank and a row per tenant per key, which is exactly what storing it once, centrally, avoids. Fineract-Platform-TenantId keeps doing what it always did: routes the rest of a request to the right bank's database. This table only decides who is allowed to make the request at all. ApiClientCredentialService.authenticate() is what an endpoint calls in place of PlatformSecurityContext#authenticatedUser() when it sees an X-Api-Key header. Security properties: - Only a SHA-256 hash of the key is ever stored -- same reason Fineract never stores a user's plaintext password. The raw key is returned exactly once, at creation (POST /v1/apiclients), and cannot be retrieved again. - Every failure path (key doesn't exist, is deactivated, caller's IP isn't on its allowlist) raises the identical 401 message, so a probing caller can't use the response to tell a wrong key apart from a valid-but-blocked one. - allowedIps is a plain comma-separated exact-match list, adequate for a small set of known partner integrations, not CIDR ranges. ApiClientAdminApiResource (create/list/deactivate/reactivate a key) is itself gated by an ordinary Fineract login restricted to a super user (ALL_FUNCTIONS) -- issuing a credential that skips login is not something to hand out casually. SecurityConfig gets one new permitAll() matcher, narrowly scoped to GET /api/*/clientaccounts/** -- the same posture this fork already uses for the IMPS and NPCI receivers: the outer Spring Security gate stays open for one specific path, and the resource itself is what actually authenticates the caller. A normal logged-in SPA user hitting that same path is unaffected -- Basic Auth still runs for any request that carries an Authorization header, permitAll only stops an anonymous one from being rejected before the handler gets a chance to check for a key instead.
This commit is contained in:
+9
@@ -163,6 +163,15 @@ public class SecurityConfig {
|
||||
// packages here without a Fineract Bearer token; security is enforced by the
|
||||
// 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
|
||||
// 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,
|
||||
// it just fails inside the handler instead of at this filter.
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clientaccounts/**")).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()
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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.JsonElement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
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.
|
||||
*/
|
||||
@Path("/v1/apiclients")
|
||||
@Component
|
||||
@Tag(name = "API Client Keys", description = "Admin management of third-party API keys used by endpoints that skip Fineract login.")
|
||||
@RequiredArgsConstructor
|
||||
public class ApiClientAdminApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final ApiClientCredentialService credentialService;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String list() {
|
||||
requireSuperUser();
|
||||
final List<Map<String, Object>> clients = credentialService.listClients();
|
||||
return gson.toJson(clients);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String deactivate(@PathParam("id") final Long id) {
|
||||
requireSuperUser();
|
||||
credentialService.setActive(id, false);
|
||||
return gson.toJson(Map.of("resourceId", id, "isActive", false));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/{id}/reactivate")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String reactivate(@PathParam("id") final Long id) {
|
||||
requireSuperUser();
|
||||
credentialService.setActive(id, true);
|
||||
return gson.toJson(Map.of("resourceId", id, "isActive", true));
|
||||
}
|
||||
|
||||
private AppUser requireSuperUser() {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
if (!user.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||
throw new WebApplicationException(Response.status(403)
|
||||
.entity("{\"message\":\"Only a super user can manage third-party API keys.\"}").type(MediaType.APPLICATION_JSON)
|
||||
.build());
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* 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 jakarta.annotation.PostConstruct;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.sql.DataSource;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Authenticates third-party callers of endpoints that are meant to be reachable WITHOUT a Fineract user
|
||||
* login -- an {@code X-Api-Key} header instead of a username/password. Same posture already used in this
|
||||
* fork for the IMPS and NPCI receivers ({@code SecurityConfig}: those paths are {@code permitAll()} at
|
||||
* the Spring Security layer, and the handler itself verifies the caller's credential): the outer gate
|
||||
* stays open for the specific path, and this class is the thing that actually decides "yes" or "no".
|
||||
*
|
||||
* {@code m_api_client} lives in the tenant-STORE database ({@code fineract_tenants}), reached here via
|
||||
* the {@code hikariTenantDataSource}-qualified {@link DataSource} -- the same bean
|
||||
* {@code TenantDatabaseUpgradeService} uses to manage that database -- rather than the ordinary
|
||||
* tenant-routed {@code JdbcTemplate} every other resource in this fork injects. That is deliberate, not
|
||||
* an oversight: a key is a caller's identity, independent of which bank's data a given request is asking
|
||||
* for, so one minted key must keep working against every tenant that exists now AND every one
|
||||
* provisioned later. A copy of this table inside each bank's own tenant database would need a manual
|
||||
* re-insert on every new bank, and every key would still need one row per tenant -- exactly what storing
|
||||
* it centrally, once, avoids. {@code Fineract-Platform-TenantId} keeps doing what it always did (routes
|
||||
* the REST of the request, e.g. {@code ClientAllAccountsApiResource}'s own client/account lookups, to
|
||||
* the right bank's database) -- this class only ever touches the one central table.
|
||||
*
|
||||
* Security properties, deliberately:
|
||||
* <ul>
|
||||
* <li>Only a SHA-256 hash of the key is ever stored ({@code m_api_client.api_key_hash}). A leaked
|
||||
* database backup can never be turned back into a usable credential -- the same reason Fineract itself
|
||||
* never stores a user's plaintext password.</li>
|
||||
* <li>The raw key is shown to the caller (well, the admin minting it) exactly once, at creation. It is
|
||||
* not retrievable afterwards, by design -- if it's lost, the only recourse is to mint a new one and
|
||||
* deactivate the old.</li>
|
||||
* <li>{@code authenticate()} raises the SAME 401 message ("Invalid or inactive API key.") whether the key
|
||||
* doesn't exist, is deactivated, or the caller's IP isn't on its allowlist -- so a probing attacker
|
||||
* cannot use the response to tell a wrong key apart from a valid-but-blocked one.</li>
|
||||
* <li>The IP allowlist is a plain comma-separated exact-match list, not CIDR ranges -- adequate for a
|
||||
* small, known set of partner integrations; if a partner calls from a rotating pool of addresses, leave
|
||||
* {@code allowedIps} null for that key rather than trying to enumerate every address.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ApiClientCredentialService {
|
||||
|
||||
private static final String GENERIC_AUTH_FAILURE = "Invalid or inactive API key.";
|
||||
// Enough random bytes that guessing is infeasible (256 bits) -- the same order of magnitude as a
|
||||
// session token, appropriate for a credential that never expires on its own.
|
||||
private static final int KEY_ENTROPY_BYTES = 32;
|
||||
|
||||
@Qualifier("hikariTenantDataSource")
|
||||
private final DataSource tenantStoreDataSource;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
// Built once from the qualified DataSource in @PostConstruct rather than injected directly as a
|
||||
// JdbcTemplate: @RequiredArgsConstructor only wires constructor parameters straight from fields,
|
||||
// and JdbcTemplate itself isn't a bean registered under this qualifier -- only the DataSource is.
|
||||
private JdbcTemplate tenantStoreJdbc;
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
this.tenantStoreJdbc = new JdbcTemplate(tenantStoreDataSource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a raw {@code X-Api-Key} header value. On success, returns the registered client's
|
||||
* display name (for the caller to log/attribute the request to) and records this call -- including
|
||||
* which tenant it was made against -- as the key's most recent use. On any failure, throws a clean
|
||||
* 401; the caller never gets far enough to tell a wrong key from a deactivated one or a blocked IP.
|
||||
*/
|
||||
public String authenticate(final String rawApiKey, final String callerIp) {
|
||||
if (StringUtils.isBlank(rawApiKey)) {
|
||||
throw unauthorized();
|
||||
}
|
||||
final String hash = sha256Hex(rawApiKey);
|
||||
final List<Map<String, Object>> rows = tenantStoreJdbc.queryForList("""
|
||||
SELECT id, client_name, is_active, allowed_ips FROM m_api_client WHERE api_key_hash = ?
|
||||
""", hash);
|
||||
if (rows.isEmpty()) {
|
||||
throw unauthorized();
|
||||
}
|
||||
final Map<String, Object> row = rows.get(0);
|
||||
if (!Boolean.TRUE.equals(row.get("is_active"))) {
|
||||
throw unauthorized();
|
||||
}
|
||||
final String allowedIps = (String) row.get("allowed_ips");
|
||||
if (StringUtils.isNotBlank(allowedIps) && !isIpAllowed(allowedIps, callerIp)) {
|
||||
throw unauthorized();
|
||||
}
|
||||
|
||||
final Long id = ((Number) row.get("id")).longValue();
|
||||
tenantStoreJdbc.update("UPDATE m_api_client SET last_used_at = ?, last_used_ip = ?, last_used_tenant = ? WHERE id = ?",
|
||||
java.sql.Timestamp.from(Instant.now()), callerIp, currentTenantId(), id);
|
||||
|
||||
return (String) row.get("client_name");
|
||||
}
|
||||
|
||||
private boolean isIpAllowed(final String allowedIps, final String callerIp) {
|
||||
if (StringUtils.isBlank(callerIp)) {
|
||||
return false;
|
||||
}
|
||||
return Arrays.stream(allowedIps.split(",")).map(String::trim).anyMatch(ip -> ip.equalsIgnoreCase(callerIp));
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin operation: mints a new key and returns it in the response -- the ONLY time the raw value
|
||||
* is ever available. {@code allowedIps} may be null/blank for no IP restriction. The resulting key
|
||||
* authenticates against every tenant, regardless of which one the admin minting it happened to be
|
||||
* logged into.
|
||||
*/
|
||||
public Map<String, Object> createClient(final String clientName, final String allowedIps, final Long createdByUserId) {
|
||||
if (StringUtils.isBlank(clientName)) {
|
||||
throw badRequest("clientName is required.");
|
||||
}
|
||||
final String rawKey = generateRawKey();
|
||||
final String hash = sha256Hex(rawKey);
|
||||
final String prefix = rawKey.substring(0, Math.min(12, rawKey.length()));
|
||||
|
||||
final Long id = tenantStoreJdbc.queryForObject("""
|
||||
INSERT INTO m_api_client (client_name, api_key_hash, api_key_prefix, allowed_ips, created_by, created_by_tenant)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""", Long.class, clientName, hash, prefix, StringUtils.isBlank(allowedIps) ? null : allowedIps, createdByUserId,
|
||||
currentTenantId());
|
||||
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("id", id);
|
||||
result.put("clientName", clientName);
|
||||
result.put("apiKey", rawKey);
|
||||
result.put("warning", "This is the only time the full API key is shown. Store it now -- it cannot be retrieved again.");
|
||||
return result;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> listClients() {
|
||||
return tenantStoreJdbc.queryForList("""
|
||||
SELECT id, client_name, api_key_prefix, is_active, allowed_ips,
|
||||
created_date, created_by_tenant, deactivated_date, last_used_at, last_used_ip, last_used_tenant
|
||||
FROM m_api_client ORDER BY created_date DESC
|
||||
""");
|
||||
}
|
||||
|
||||
public void setActive(final Long id, final boolean active) {
|
||||
final int updated = tenantStoreJdbc.update(
|
||||
"UPDATE m_api_client SET is_active = ?, deactivated_date = ? WHERE id = ?", active,
|
||||
active ? null : java.sql.Timestamp.from(Instant.now()), id);
|
||||
if (updated == 0) {
|
||||
throw notFound("No API client with id " + id);
|
||||
}
|
||||
}
|
||||
|
||||
private String currentTenantId() {
|
||||
try {
|
||||
return ThreadLocalContextUtil.getTenant().getTenantIdentifier();
|
||||
} catch (final RuntimeException e) {
|
||||
// Defensive only: every request through SecurityConfig's matcher chain has a tenant
|
||||
// resolved before it reaches here (see TenantAwareBasicAuthenticationFilter), so this
|
||||
// should be unreachable in practice.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String generateRawKey() {
|
||||
final byte[] bytes = new byte[KEY_ENTROPY_BYTES];
|
||||
secureRandom.nextBytes(bytes);
|
||||
// URL-safe, no padding: safe to put directly in a header value with no further encoding.
|
||||
return "cbsx_" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
private String sha256Hex(final String raw) {
|
||||
try {
|
||||
final MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
final byte[] hashBytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
|
||||
final StringBuilder hex = new StringBuilder(hashBytes.length * 2);
|
||||
for (final byte b : hashBytes) {
|
||||
hex.append(String.format("%02x", b));
|
||||
}
|
||||
return hex.toString();
|
||||
} catch (final NoSuchAlgorithmException e) {
|
||||
// SHA-256 is a mandatory JCE algorithm on every JVM distribution Fineract supports --
|
||||
// this can only mean a broken JVM install, not a recoverable runtime condition.
|
||||
throw new IllegalStateException("SHA-256 is unavailable in this JVM", e);
|
||||
}
|
||||
}
|
||||
|
||||
private WebApplicationException unauthorized() {
|
||||
// Naming the tenant the caller sent is safe here -- it is the header value they themselves
|
||||
// put on the request, not something this check reveals to them -- and it turns "why did my
|
||||
// key fail" from a guess into something checkable: was this call even reaching the bank the
|
||||
// caller thought it was?
|
||||
final String tenantId = currentTenantId();
|
||||
final String message = tenantId != null ? GENERIC_AUTH_FAILURE + " (tenant: " + tenantId + ")" : GENERIC_AUTH_FAILURE;
|
||||
return jsonError(401, message);
|
||||
}
|
||||
|
||||
private WebApplicationException badRequest(final String message) {
|
||||
return jsonError(400, message);
|
||||
}
|
||||
|
||||
private WebApplicationException notFound(final String message) {
|
||||
return jsonError(404, message);
|
||||
}
|
||||
|
||||
private WebApplicationException jsonError(final int status, final String message) {
|
||||
final String body = "{\"httpStatusCode\":\"" + status + "\",\"message\":\"" + message.replace("\"", "'") + "\"}";
|
||||
return new WebApplicationException(Response.status(status).entity(body).type(MediaType.APPLICATION_JSON).build());
|
||||
}
|
||||
}
|
||||
+1
@@ -31,4 +31,5 @@
|
||||
<include file="parts/0008_encrypt_existing_ro_tenant_passwords.xml" relativeToChangelogFile="true"/>
|
||||
<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"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
<?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">
|
||||
|
||||
<!-- Registry of third-party API keys, for endpoints that must be callable without a Fineract user
|
||||
login (e.g. GET /v1/clientaccounts). Lives in the tenant-STORE database (fineract_tenants),
|
||||
not any one bank's own tenant database: a key is a caller identity, independent of which
|
||||
tenant's data a given request asks for, and a key minted today must keep working for every
|
||||
tenant that exists now AND every one provisioned later: a per-tenant copy of this table
|
||||
would need a manual re-insert for each new bank, and would still need one row per tenant for
|
||||
every key, which is exactly what a central table avoids.
|
||||
|
||||
Only a SHA-256 hash of the key is stored, never the raw key: a leaked database backup can
|
||||
never be turned back into a usable credential. api_key_prefix keeps the first 12 characters
|
||||
of the raw key alongside the hash purely so an admin can tell two keys apart in a listing: it
|
||||
is not, by itself, enough to authenticate. -->
|
||||
<changeSet id="0011_create_api_client" author="cbs-nitesh">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<tableExists tableName="m_api_client"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_api_client">
|
||||
<column name="id" type="BIGSERIAL">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="client_name" type="VARCHAR(200)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="api_key_hash" type="VARCHAR(64)">
|
||||
<constraints nullable="false" unique="true"/>
|
||||
</column>
|
||||
<column name="api_key_prefix" type="VARCHAR(12)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="is_active" type="BOOLEAN" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Comma-separated allowlist. NULL = no IP restriction (key alone is enough). -->
|
||||
<column name="allowed_ips" type="VARCHAR(500)"/>
|
||||
<column name="created_by" type="BIGINT"/>
|
||||
<column name="created_by_tenant" type="VARCHAR(100)"/>
|
||||
<column name="created_date" type="TIMESTAMP WITHOUT TIME ZONE" defaultValueComputed="CURRENT_TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="deactivated_date" type="TIMESTAMP WITHOUT TIME ZONE"/>
|
||||
<column name="last_used_at" type="TIMESTAMP WITHOUT TIME ZONE"/>
|
||||
<column name="last_used_ip" type="VARCHAR(64)"/>
|
||||
<column name="last_used_tenant" type="VARCHAR(100)"/>
|
||||
</createTable>
|
||||
|
||||
<createIndex indexName="idx_api_client_hash" tableName="m_api_client">
|
||||
<column name="api_key_hash"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user