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:
+6
@@ -68,6 +68,7 @@ public class TenantDatabaseUpgradeService implements InitializingBean {
|
|||||||
private final ExtendedSpringLiquibaseFactory liquibaseFactory;
|
private final ExtendedSpringLiquibaseFactory liquibaseFactory;
|
||||||
private final TenantDataSourceFactory tenantDataSourceFactory;
|
private final TenantDataSourceFactory tenantDataSourceFactory;
|
||||||
private final Environment environment;
|
private final Environment environment;
|
||||||
|
private final TenantInitialUserProvisioner initialUserProvisioner;
|
||||||
|
|
||||||
// DO NOT REMOVE! Required for liquibase custom task initialization
|
// DO NOT REMOVE! Required for liquibase custom task initialization
|
||||||
private final List<CustomTaskChange> customTaskChangesForDependencyInjection;
|
private final List<CustomTaskChange> customTaskChangesForDependencyInjection;
|
||||||
@@ -195,10 +196,15 @@ public class TenantDatabaseUpgradeService implements InitializingBean {
|
|||||||
// configuration
|
// configuration
|
||||||
// settings, and we should not use them to control the script order
|
// settings, and we should not use them to control the script order
|
||||||
if (databaseStateVerifier.isFirstLiquibaseMigration(tenantDataSource)) {
|
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,
|
ExtendedSpringLiquibase liquibase = liquibaseFactory.create(tenantDataSource, TENANT_DB_CONTEXT,
|
||||||
CUSTOM_CHANGELOG_CONTEXT, INITIAL_SWITCH_CONTEXT, tenant.getTenantIdentifier());
|
CUSTOM_CHANGELOG_CONTEXT, INITIAL_SWITCH_CONTEXT, tenant.getTenantIdentifier());
|
||||||
applyInitialLiquibase(tenantDataSource, liquibase, tenant.getTenantIdentifier(),
|
applyInitialLiquibase(tenantDataSource, liquibase, tenant.getTenantIdentifier(),
|
||||||
(ds) -> !databaseStateVerifier.isTenantOnLatestUpgradableVersion(ds));
|
(ds) -> !databaseStateVerifier.isTenantOnLatestUpgradableVersion(ds));
|
||||||
|
if (brandNewTenant) {
|
||||||
|
initialUserProvisioner.provision(tenantDataSource, tenant.getTenantIdentifier());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SpringLiquibase tenantLiquibase = liquibaseFactory.create(tenantDataSource, TENANT_DB_CONTEXT, CUSTOM_CHANGELOG_CONTEXT,
|
SpringLiquibase tenantLiquibase = liquibaseFactory.create(tenantDataSource, TENANT_DB_CONTEXT, CUSTOM_CHANGELOG_CONTEXT,
|
||||||
tenant.getTenantIdentifier());
|
tenant.getTenantIdentifier());
|
||||||
|
|||||||
+88
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+2
-2
@@ -424,8 +424,8 @@ public class SavingsPooledInterestPostingService {
|
|||||||
* columns are NOT NULL, so entries are stamped to the system account the scheduler uses.
|
* columns are NOT NULL, so entries are stamped to the system account the scheduler uses.
|
||||||
*/
|
*/
|
||||||
private Long systemUserId() {
|
private Long systemUserId() {
|
||||||
final List<Long> ids = jdbcTemplate.queryForList(
|
// id 2 is the seeded system user; its username is per-tenant now, so it is found by id
|
||||||
"SELECT id FROM m_appuser WHERE username = 'system' ORDER BY id LIMIT 1", Long.class);
|
final List<Long> ids = jdbcTemplate.queryForList("SELECT id FROM m_appuser WHERE id = 2", Long.class);
|
||||||
return ids.isEmpty() ? 1L : ids.get(0);
|
return ids.isEmpty() ? 1L : ids.get(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -469,8 +469,8 @@ public class FixedDepositInterestPostingService {
|
|||||||
* scheduler's own postings always were.
|
* scheduler's own postings always were.
|
||||||
*/
|
*/
|
||||||
private Long systemUserId() {
|
private Long systemUserId() {
|
||||||
final List<Long> ids = this.jdbcTemplate.queryForList("SELECT id FROM m_appuser WHERE username = 'system' ORDER BY id LIMIT 1",
|
// id 2 is the seeded system user; its username is per-tenant now, so it is found by id
|
||||||
Long.class);
|
final List<Long> ids = this.jdbcTemplate.queryForList("SELECT id FROM m_appuser WHERE id = 2", Long.class);
|
||||||
return ids.isEmpty() ? 1L : ids.get(0);
|
return ids.isEmpty() ? 1L : ids.get(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,7 +159,9 @@ public class TestConfiguration {
|
|||||||
FineractProperties fineractProperties, Environment environment,
|
FineractProperties fineractProperties, Environment environment,
|
||||||
List<CustomTaskChange> customTaskChangesForDependencyInjection) {
|
List<CustomTaskChange> customTaskChangesForDependencyInjection) {
|
||||||
return new TenantDatabaseUpgradeService(tenantDetailsService, tenantDataSource, fineractProperties, tenantDatabaseStateVerifier,
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+3
-1
@@ -189,6 +189,8 @@ public class LiquibaseStepDefinitions implements En {
|
|||||||
.willReturn(tenantLiquibase);
|
.willReturn(tenantLiquibase);
|
||||||
|
|
||||||
tenantDatabaseUpgradeService = new TenantDatabaseUpgradeService(tenantDetailsService, tenantStoreDataSource, fineractProperties,
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user