diff --git a/backend/src/main/java/org/raddatz/familienarchiv/user/UserDataInitializer.java b/backend/src/main/java/org/raddatz/familienarchiv/user/UserDataInitializer.java index e7c9f0c1..590ee8ae 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/user/UserDataInitializer.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/user/UserDataInitializer.java @@ -20,6 +20,7 @@ import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; +import org.springframework.core.env.Environment; import org.springframework.security.crypto.password.PasswordEncoder; import java.time.LocalDate; @@ -31,19 +32,39 @@ import java.util.Set; @DependsOn("flyway") public class UserDataInitializer { - @Value("${app.admin.email:admin@familyarchive.local}") + static final String DEFAULT_ADMIN_EMAIL = "admin@familienarchiv.local"; + static final String DEFAULT_ADMIN_PASSWORD = "admin123"; + + @Value("${app.admin.email:" + DEFAULT_ADMIN_EMAIL + "}") private String adminEmail; - @Value("${app.admin.password:admin123}") + @Value("${app.admin.password:" + DEFAULT_ADMIN_PASSWORD + "}") private String adminPassword; private final AppUserRepository userRepository; private final UserGroupRepository groupRepository; + private final Environment environment; @Bean public CommandLineRunner initAdminUser(PasswordEncoder passwordEncoder) { return args -> { if (userRepository.findByEmail(adminEmail).isEmpty()) { + // Fail-closed in production: refuse to seed with the well-known + // defaults. Otherwise an operator who forgets APP_ADMIN_USERNAME + // / APP_ADMIN_PASSWORD locks production to admin@…/admin123 PERMANENTLY + // (UserDataInitializer only seeds when the row is missing — see #513). + // Allowed in dev/test/e2e because those run without secrets configured. + boolean isLocalProfile = environment.matchesProfiles("dev", "test", "e2e"); + if (!isLocalProfile + && (DEFAULT_ADMIN_EMAIL.equals(adminEmail) + || DEFAULT_ADMIN_PASSWORD.equals(adminPassword))) { + throw new IllegalStateException( + "Refusing to seed admin user with default credentials outside " + + "the dev/test/e2e profiles. Set APP_ADMIN_USERNAME and " + + "APP_ADMIN_PASSWORD to non-default values before first boot — " + + "this lock-in is permanent." + ); + } log.info("Kein Admin-User '{}' gefunden. Erstelle Default-Admin...", adminEmail); UserGroup adminGroup = UserGroup.builder() diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 6e12b9f6..179ed09e 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -69,7 +69,11 @@ app: from: ${APP_MAIL_FROM:noreply@familienarchiv.local} admin: - username: ${APP_ADMIN_USERNAME:admin} + # Key must be `email`, not `username` — UserDataInitializer reads + # `${app.admin.email:...}`. The env-var name stays APP_ADMIN_USERNAME + # to match the existing Gitea secrets and DEPLOYMENT.md §3.3. + # See #513. + email: ${APP_ADMIN_USERNAME:admin@familienarchiv.local} password: ${APP_ADMIN_PASSWORD:admin123} import: diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/AdminSeedFailClosedTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/AdminSeedFailClosedTest.java new file mode 100644 index 00000000..16471496 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/AdminSeedFailClosedTest.java @@ -0,0 +1,123 @@ +package org.raddatz.familienarchiv.user; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.CommandLineRunner; +import org.springframework.core.env.Environment; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * UserDataInitializer must refuse to seed the admin user with the hardcoded + * dev defaults when running outside the {@code dev} profile. + * + *

Why this matters: per DEPLOYMENT.md §3.5 and ADR-011, the admin password + * is permanently locked on first deploy (UserDataInitializer only seeds when + * the row is missing). If an operator forgets to set {@code APP_ADMIN_USERNAME} + * / {@code APP_ADMIN_PASSWORD}, prod silently boots with the well-known dev + * defaults — a credential-disclosure foot-gun, not a config typo. See #513. + */ +@ExtendWith(MockitoExtension.class) +class AdminSeedFailClosedTest { + + @Mock AppUserRepository userRepository; + @Mock UserGroupRepository groupRepository; + @Mock Environment environment; + @Mock PasswordEncoder passwordEncoder; + + UserDataInitializer initializer; + + @BeforeEach + void setUp() { + initializer = new UserDataInitializer(userRepository, groupRepository, environment); + } + + @Test + void refuses_to_seed_when_email_is_default_and_profile_is_not_dev() throws Exception { + when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty()); + when(environment.matchesProfiles("dev", "test", "e2e")).thenReturn(false); + ReflectionTestUtils.setField(initializer, "adminEmail", UserDataInitializer.DEFAULT_ADMIN_EMAIL); + ReflectionTestUtils.setField(initializer, "adminPassword", "operator-set-this-one"); + + CommandLineRunner runner = initializer.initAdminUser(passwordEncoder); + + assertThatThrownBy(() -> runner.run()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("default credentials") + .hasMessageContaining("permanent"); + + verify(userRepository, never()).save(org.mockito.ArgumentMatchers.any()); + } + + @Test + void refuses_to_seed_when_password_is_default_and_profile_is_not_dev() throws Exception { + when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty()); + when(environment.matchesProfiles("dev", "test", "e2e")).thenReturn(false); + ReflectionTestUtils.setField(initializer, "adminEmail", "admin@archiv.raddatz.cloud"); + ReflectionTestUtils.setField(initializer, "adminPassword", UserDataInitializer.DEFAULT_ADMIN_PASSWORD); + + CommandLineRunner runner = initializer.initAdminUser(passwordEncoder); + + assertThatThrownBy(() -> runner.run()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("default credentials"); + } + + @Test + void allows_seed_when_both_values_are_set_and_profile_is_not_dev() throws Exception { + when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty()); + when(environment.matchesProfiles("dev", "test", "e2e")).thenReturn(false); + when(passwordEncoder.encode(anyString())).thenReturn("$2a$10$stub"); + ReflectionTestUtils.setField(initializer, "adminEmail", "admin@archiv.raddatz.cloud"); + ReflectionTestUtils.setField(initializer, "adminPassword", "a-real-strong-password"); + + CommandLineRunner runner = initializer.initAdminUser(passwordEncoder); + runner.run(); + + verify(userRepository).save(org.mockito.ArgumentMatchers.any(AppUser.class)); + } + + @Test + void allows_seed_with_defaults_when_profile_is_dev() throws Exception { + when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty()); + when(environment.matchesProfiles("dev", "test", "e2e")).thenReturn(true); + when(passwordEncoder.encode(anyString())).thenReturn("$2a$10$stub"); + ReflectionTestUtils.setField(initializer, "adminEmail", UserDataInitializer.DEFAULT_ADMIN_EMAIL); + ReflectionTestUtils.setField(initializer, "adminPassword", UserDataInitializer.DEFAULT_ADMIN_PASSWORD); + + CommandLineRunner runner = initializer.initAdminUser(passwordEncoder); + runner.run(); + + verify(userRepository).save(org.mockito.ArgumentMatchers.any(AppUser.class)); + } + + @Test + void does_not_check_defaults_when_admin_already_exists() throws Exception { + AppUser existing = AppUser.builder() + .email("someone@example.com") + .password("$2a$10$stub") + .build(); + when(userRepository.findByEmail(anyString())).thenReturn(Optional.of(existing)); + ReflectionTestUtils.setField(initializer, "adminEmail", UserDataInitializer.DEFAULT_ADMIN_EMAIL); + ReflectionTestUtils.setField(initializer, "adminPassword", UserDataInitializer.DEFAULT_ADMIN_PASSWORD); + + CommandLineRunner runner = initializer.initAdminUser(passwordEncoder); + runner.run(); + + verify(userRepository, never()).save(org.mockito.ArgumentMatchers.any()); + // Importantly, no IllegalStateException — re-deploys must not panic over + // historical default-seeded data they cannot retroactively fix. + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/AdminSeedPropertyKeyTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/AdminSeedPropertyKeyTest.java new file mode 100644 index 00000000..1b835e26 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/AdminSeedPropertyKeyTest.java @@ -0,0 +1,95 @@ +package org.raddatz.familienarchiv.user; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.ConfigurationPropertySources; +import org.springframework.core.env.PropertiesPropertySource; +import org.springframework.core.io.ClassPathResource; + +import java.lang.reflect.Field; +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Pins the admin-seed property key contract. {@code UserDataInitializer} reads + * {@code @Value("${app.admin.email:...}")} and {@code @Value("${app.admin.password:...}")}. + * The yaml MUST expose those exact keys, not e.g. {@code app.admin.username}, or + * the env vars {@code APP_ADMIN_USERNAME} / {@code APP_ADMIN_PASSWORD} are + * silently ignored and the admin user gets seeded with the hardcoded defaults. + * + *

Discovered as a HIGH bug during the production-deploy bootstrap (#513): on + * first deploy the prod admin password is permanently locked to whatever ends + * up in the database, so a key-name mismatch would lock prod to the dev defaults + * {@code admin@familyarchive.local} / {@code admin123}. + * + *

No Spring context — Binder reads application.yaml directly. + */ +class AdminSeedPropertyKeyTest { + + @Test + void admin_email_key_binds_from_yaml() { + Binder binder = binderFromApplicationYaml(); + + String email = binder.bind("app.admin.email", String.class) + .orElseThrow(() -> new AssertionError( + "app.admin.email is missing from application.yaml. " + + "UserDataInitializer reads this exact key; if the yaml uses " + + "a different name (e.g. 'username'), the env var " + + "APP_ADMIN_USERNAME is silently ignored.")); + + assertThat(email) + .as("app.admin.email must resolve from APP_ADMIN_USERNAME or its default") + .isNotBlank(); + } + + @Test + void admin_password_key_binds_from_yaml() { + Binder binder = binderFromApplicationYaml(); + + String password = binder.bind("app.admin.password", String.class) + .orElseThrow(() -> new AssertionError( + "app.admin.password is missing from application.yaml. " + + "UserDataInitializer reads this exact key.")); + + assertThat(password) + .as("app.admin.password must resolve from APP_ADMIN_PASSWORD or its default") + .isNotBlank(); + } + + @Test + void userDataInitializer_reads_app_admin_email_not_username() throws NoSuchFieldException { + // Pin the Java side too: a future rename of the @Value placeholder + // (e.g. back to `${app.admin.username:...}`) would silently break the + // binding while the yaml-side assertions above still pass. See #513. + Field field = UserDataInitializer.class.getDeclaredField("adminEmail"); + Value annotation = field.getAnnotation(Value.class); + assertThat(annotation) + .as("UserDataInitializer.adminEmail must be @Value-annotated") + .isNotNull(); + assertThat(annotation.value()) + .as("UserDataInitializer must read app.admin.email — not username or any other key") + .startsWith("${app.admin.email:"); + } + + @Test + void userDataInitializer_reads_app_admin_password() throws NoSuchFieldException { + Field field = UserDataInitializer.class.getDeclaredField("adminPassword"); + Value annotation = field.getAnnotation(Value.class); + assertThat(annotation).isNotNull(); + assertThat(annotation.value()) + .as("UserDataInitializer must read app.admin.password") + .startsWith("${app.admin.password:"); + } + + private Binder binderFromApplicationYaml() { + YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean(); + yaml.setResources(new ClassPathResource("application.yaml")); + Properties props = yaml.getObject(); + assertThat(props).as("application.yaml must be on the classpath").isNotNull(); + return new Binder(ConfigurationPropertySources.from( + new PropertiesPropertySource("application", props))); + } +}