feat(tenants): personalise seeded user credentials for a brand-new tenant

Adds TenantInitialUserProvisioner: right after the very first Liquibase
migration of a tenant that was never on Flyway, renames the three
seeded users (ids 1-3, otherwise shipping as mifos/system/interopUser
with the public default password) to <tenantIdentifier>_admin/_system/
_interop, with a password from fineract.tenant.initial-user-password
or a built-in default. An existing (Flyway-era) tenant already has its
real users and is left untouched. Wired through TenantDatabaseUpgradeService
and its two test configurations.

Also fixes the two pooled interest-posting services' system-user
lookup, which read username = 'system' — now wrong since that username
is personalised per tenant — to find the seeded system user by its
fixed id (2) instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 22:41:25 +05:30
co-authored by Claude Sonnet 5
parent 1a914a3de0
commit 3d3ce92e93
6 changed files with 104 additions and 6 deletions
@@ -68,6 +68,7 @@ public class TenantDatabaseUpgradeService implements InitializingBean {
private final ExtendedSpringLiquibaseFactory liquibaseFactory;
private final TenantDataSourceFactory tenantDataSourceFactory;
private final Environment environment;
private final TenantInitialUserProvisioner initialUserProvisioner;
// DO NOT REMOVE! Required for liquibase custom task initialization
private final List<CustomTaskChange> customTaskChangesForDependencyInjection;
@@ -195,10 +196,15 @@ public class TenantDatabaseUpgradeService implements InitializingBean {
// configuration
// settings, and we should not use them to control the script order
if (databaseStateVerifier.isFirstLiquibaseMigration(tenantDataSource)) {
// a Flyway-era database already has its real users; only a brand-new tenant gets the seeded ones
final boolean brandNewTenant = !databaseStateVerifier.isFlywayPresent(tenantDataSource);
ExtendedSpringLiquibase liquibase = liquibaseFactory.create(tenantDataSource, TENANT_DB_CONTEXT,
CUSTOM_CHANGELOG_CONTEXT, INITIAL_SWITCH_CONTEXT, tenant.getTenantIdentifier());
applyInitialLiquibase(tenantDataSource, liquibase, tenant.getTenantIdentifier(),
(ds) -> !databaseStateVerifier.isTenantOnLatestUpgradableVersion(ds));
if (brandNewTenant) {
initialUserProvisioner.provision(tenantDataSource, tenant.getTenantIdentifier());
}
}
SpringLiquibase tenantLiquibase = liquibaseFactory.create(tenantDataSource, TENANT_DB_CONTEXT, CUSTOM_CHANGELOG_CONTEXT,
tenant.getTenantIdentifier());
@@ -0,0 +1,88 @@
/**
* 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.core.service.migration;
import java.util.Locale;
import javax.sql.DataSource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.env.Environment;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
/**
* Personalises the three users that the initial Liquibase data seeds into every brand-new tenant database (ids 1-3,
* which otherwise ship with the well-known {@code mifos} / {@code system} / {@code interopUser} usernames and the
* public default password).
*
* Runs only right after the very first migration of a tenant that was never on Flyway, so existing tenants are never
* touched. The seeded changeSet itself is deliberately left alone: editing it would change its Liquibase checksum and
* break validation on every tenant that already ran it.
*
* Usernames become {@code <tenantIdentifier>_admin}, {@code <tenantIdentifier>_system} and
* {@code <tenantIdentifier>_interop}. All three share one password: the value of
* {@code fineract.tenant.initial-user-password} (env {@code FINERACT_TENANT_INITIAL_USER_PASSWORD}) when set,
* otherwise {@link #DEFAULT_PASSWORD}.
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class TenantInitialUserProvisioner {
static final String PASSWORD_PROPERTY = "fineract.tenant.initial-user-password";
private static final long ADMIN_ID = 1L;
private static final long SYSTEM_ID = 2L;
private static final long INTEROP_ID = 3L;
// Meets the "strong" password policy (upper, lower, digit, special, no space, under 50 chars).
static final String DEFAULT_PASSWORD = "Cbs@Nexora#2026!Secure";
private final Environment environment;
public void provision(final DataSource dataSource, final String tenantIdentifier) {
final JdbcTemplate jdbc = new JdbcTemplate(dataSource);
final String prefix = usernamePrefix(tenantIdentifier);
final String configured = environment.getProperty(PASSWORD_PROPERTY);
final boolean overridden = configured != null && !configured.isBlank();
final String password = overridden ? configured : DEFAULT_PASSWORD;
final PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
final String encoded = encoder.encode(password);
update(jdbc, ADMIN_ID, prefix + "_admin", encoded);
update(jdbc, SYSTEM_ID, prefix + "_system", encoded);
update(jdbc, INTEROP_ID, prefix + "_interop", encoded);
log.warn("Tenant '{}' initial users created: {}_admin, {}_system, {}_interop (password: {}). Change it after first login.",
tenantIdentifier, prefix, prefix, prefix, overridden ? "from " + PASSWORD_PROPERTY : "built-in default");
}
private void update(final JdbcTemplate jdbc, final long id, final String username, final String encodedPassword) {
jdbc.update("UPDATE m_appuser SET username = ?, password = ?, last_time_password_updated = CURRENT_DATE WHERE id = ?", username,
encodedPassword, id);
}
static String usernamePrefix(final String tenantIdentifier) {
final String cleaned = tenantIdentifier.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "_").replaceAll("^_+|_+$", "");
return cleaned.isEmpty() ? "tenant" : cleaned;
}
}
@@ -424,8 +424,8 @@ public class SavingsPooledInterestPostingService {
* columns are NOT NULL, so entries are stamped to the system account the scheduler uses.
*/
private Long systemUserId() {
final List<Long> ids = jdbcTemplate.queryForList(
"SELECT id FROM m_appuser WHERE username = 'system' ORDER BY id LIMIT 1", Long.class);
// id 2 is the seeded system user; its username is per-tenant now, so it is found by id
final List<Long> ids = jdbcTemplate.queryForList("SELECT id FROM m_appuser WHERE id = 2", Long.class);
return ids.isEmpty() ? 1L : ids.get(0);
}
@@ -469,8 +469,8 @@ public class FixedDepositInterestPostingService {
* scheduler's own postings always were.
*/
private Long systemUserId() {
final List<Long> ids = this.jdbcTemplate.queryForList("SELECT id FROM m_appuser WHERE username = 'system' ORDER BY id LIMIT 1",
Long.class);
// id 2 is the seeded system user; its username is per-tenant now, so it is found by id
final List<Long> ids = this.jdbcTemplate.queryForList("SELECT id FROM m_appuser WHERE id = 2", Long.class);
return ids.isEmpty() ? 1L : ids.get(0);
}
@@ -159,7 +159,9 @@ public class TestConfiguration {
FineractProperties fineractProperties, Environment environment,
List<CustomTaskChange> customTaskChangesForDependencyInjection) {
return new TenantDatabaseUpgradeService(tenantDetailsService, tenantDataSource, fineractProperties, tenantDatabaseStateVerifier,
liquibaseFactory, tenantDataSourceFactory, environment, customTaskChangesForDependencyInjection);
liquibaseFactory, tenantDataSourceFactory, environment,
new org.apache.fineract.infrastructure.core.service.migration.TenantInitialUserProvisioner(environment),
customTaskChangesForDependencyInjection);
}
/**
@@ -189,6 +189,8 @@ public class LiquibaseStepDefinitions implements En {
.willReturn(tenantLiquibase);
tenantDatabaseUpgradeService = new TenantDatabaseUpgradeService(tenantDetailsService, tenantStoreDataSource, fineractProperties,
databaseStateVerifier, liquibaseFactory, tenantDataSourceFactory, environment, Arrays.asList(tenantPasswordEncryptor));
databaseStateVerifier, liquibaseFactory, tenantDataSourceFactory, environment,
new org.apache.fineract.infrastructure.core.service.migration.TenantInitialUserProvisioner(environment),
Arrays.asList(tenantPasswordEncryptor));
}
}