diff --git a/.env.example b/.env.example index 536781f..22ae5b4 100644 --- a/.env.example +++ b/.env.example @@ -20,8 +20,14 @@ SPRING_PROFILES_ACTIVE=dev JWT_SECRET=CHANGE_ME_GENERATE_WITH_OPENSSL_RAND_BASE64_64 JWT_EXPIRATION=86400000 +# FIRST ADMIN BOOTSTRAP (disabled by default) +# Enable only for the first controlled provisioning run. +ADMIN_BOOTSTRAP_ENABLED=false +ADMIN_BOOTSTRAP_EMAIL=admin@example.test +ADMIN_BOOTSTRAP_PASSWORD=CHANGE_ME_PRIVATE_BOOTSTRAP_PASSWORD + # Server SERVER_PORT=8080 # Volume (absolute path - adjust according to your system) -FILE_DATA_POSTGRES=/path/to/your/data/postgres_wallet \ No newline at end of file +FILE_DATA_POSTGRES=/path/to/your/data/postgres_wallet diff --git a/AGENTS.md b/AGENTS.md index f2b0ac4..948d802 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -256,4 +256,10 @@ Before opening a PR, verify: - [ ] `@Transactional` on financial operations - [ ] OWASP comments on security decisions - [ ] `@Schema` on new DTOs for Swagger -- [ ] `@Tag` on new controllers for Swagger grouping \ No newline at end of file +- [ ] `@Tag` on new controllers for Swagger grouping + +## Git Safety Rules +- Do not create commits unless the user explicitly authorizes the commit in the current message. +- Do not run `git push`, `git merge`, rebase, revert, cherry-pick, or create pull requests unless explicitly. +- Reading git history, status, and diffs is allowed. +- Before any Git write operation, explain the exact command and wait for confirmation. diff --git a/database/07-seed.sql b/database/07-seed.sql deleted file mode 100644 index ffb300f..0000000 --- a/database/07-seed.sql +++ /dev/null @@ -1,15 +0,0 @@ --- TEST DATA (DEVELOPMENT ONLY) - --- Test user (password: Test1234!) -INSERT INTO users (email, password_hash, role, email_verified) VALUES -('admin@test.com', '$2a$10$N9qo8uLOickgx2ZMRZoMye1J8FRKp0LEzKd6KPXB5z.Q7.QN0lLBa', 'ADMIN', TRUE), -('user@test.com', '$2a$10$N9qo8uLOickgx2ZMRZoMye1J8FRKp0LEzKd6KPXB5z.Q7.QN0lLBa', 'USER', TRUE); - --- Test wallets -INSERT INTO wallets (user_id, balance, currency) VALUES -((SELECT id FROM users WHERE email = 'admin@test.com'), 1000.00, 'USD'), -((SELECT id FROM users WHERE email = 'user@test.com'), 500.00, 'USD'); - --- Test log -INSERT INTO audit_logs (user_id, action, severity_level, details) VALUES -((SELECT id FROM users WHERE email = 'admin@test.com'), 'USER_LOGIN', 'INFO', '{"ip": "127.0.0.1"}'); \ No newline at end of file diff --git a/database/README.md b/database/README.md index 80f5105..891c024 100644 --- a/database/README.md +++ b/database/README.md @@ -1,7 +1,8 @@ # Database — Secure Wallet API PostgreSQL 16 schema for a secure digital wallet system. -All scripts run automatically on first Docker startup via `docker-entrypoint-initdb.d`, in numbered order. +Schema scripts run automatically on first Docker startup via `docker-entrypoint-initdb.d`, in numbered order. +No development seed data is executed automatically. Test fixtures must be provisioned separately. --- @@ -15,7 +16,7 @@ All scripts run automatically on first Docker startup via `docker-entrypoint-ini | `04-index.sql` | 4th | Performance indexes (partial, composite, GIN) | | `05-triggers.sql` | 5th | Auto-update timestamps, balance validation, audit triggers | | `06-functions.sql` | 6th | ACID transaction processor, session cleanup | -| `07-seed.sql` | 7th | Development test data only | +| — | — | No seed data; administrators are provisioned by the application bootstrap procedure | --- @@ -172,4 +173,4 @@ docker compose --profile tools up -d # pgAdmin: http://localhost:5050 # PostgreSQL: localhost:5432 -``` \ No newline at end of file +``` diff --git a/docker-compose.yml b/docker-compose.yml index 6e1d0c0..69bd6e7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,6 +51,10 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} # JWT from .env JWT_SECRET: ${JWT_SECRET} + # First ADMIN bootstrap; disabled unless explicitly enabled in .env. + ADMIN_BOOTSTRAP_ENABLED: ${ADMIN_BOOTSTRAP_ENABLED:-false} + ADMIN_BOOTSTRAP_EMAIL: ${ADMIN_BOOTSTRAP_EMAIL:-} + ADMIN_BOOTSTRAP_PASSWORD: ${ADMIN_BOOTSTRAP_PASSWORD:-} SERVER_PORT: ${SERVER_PORT:-8080} ports: - "${SERVER_PORT:-8080}:${SERVER_PORT:-8080}" @@ -82,4 +86,4 @@ services: networks: wallet-network: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/docs/IMPLEMENTATION-PLAN.md b/docs/IMPLEMENTATION-PLAN.md index 7854d2a..7bc9837 100644 --- a/docs/IMPLEMENTATION-PLAN.md +++ b/docs/IMPLEMENTATION-PLAN.md @@ -59,20 +59,20 @@ Postman critical-flow collection **Current problem** -`database/07-seed.sql` creates an administrator with publicly know credentials during PostgreSQL initialization. +The former `database/07-seed.sql` created an administrator with publicly known credentials during PostgreSQL initialization. It has been removed from the database initialization directory. **Expected behavior** -- Remove 07-seed.sql if no application or test dependency requirest it. +- Remove the seed from the repository's initialization directory; provision administrators through the controlled application bootstrap instead. - Configure Docker Compose to initialize only the required database schema. - Do not create default users or administrators during SQL initialization. - Keep Docker Compose as the primary way to start PostgreSQL and Spring Boot. **Acceptance criteria** -- [ ] No default administrator is created by SQL initialization. -- [ ] No public administrator password remains in the repository's current tracked files. -- [ ] PostgreSQL initializes correctly using Docker Compose. +- [x] No default administrator is created by SQL initialization path (verified with a temporary clean PostgreSQL container). +- [x] No public administrator password remains in the repository's database scripts. +- [x] PostgreSQL initializes correctly using Docker Compose and the current schema scripts. - [ ] Existing tests continue to pass. ### SEC-002 — Add controlled one-time bootstrap for the first administrator diff --git a/src/main/java/com/wallet/secure/config/AdminBootstrapProperties.java b/src/main/java/com/wallet/secure/config/AdminBootstrapProperties.java new file mode 100644 index 0000000..9ccee42 --- /dev/null +++ b/src/main/java/com/wallet/secure/config/AdminBootstrapProperties.java @@ -0,0 +1,37 @@ +package com.wallet.secure.config; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * Private, opt-in input for provisioning the first business administrator. + * + * Environment variables map to these properties as: + * ADMIN_BOOTSTRAP_ENABLED, ADMIN_BOOTSTRAP_EMAIL and ADMIN_BOOTSTRAP_PASSWORD. + */ +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "admin.bootstrap") +public class AdminBootstrapProperties { + + private boolean enabled; + + @Email(message = "Email no válido") + @NotBlank(message = "Email obligatorio") + private String email; + + @NotBlank(message = "Contraseña obligatoria") + @Size(min = 12, max = 72, message = "La contraseña debe tener entre 12 y 72 caracteres") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&_.#^])[A-Za-z\\d@$!%*?&_.#^]{12,72}$", + message = "La contraseña debe incluir mayúscula, minúscula, número y carácter especial" + ) + private String password; +} diff --git a/src/main/java/com/wallet/secure/user/repository/UserRepository.java b/src/main/java/com/wallet/secure/user/repository/UserRepository.java index d41dcf0..c4607c4 100644 --- a/src/main/java/com/wallet/secure/user/repository/UserRepository.java +++ b/src/main/java/com/wallet/secure/user/repository/UserRepository.java @@ -47,6 +47,12 @@ public interface UserRepository extends JpaRepository { */ boolean existsByEmail(String email); + /** + * Used by the one-time admin bootstrap to enforce a single initial ADMIN. + * OWASP A01: privileged role provisioning is not exposed through public registration. + */ + boolean existsByRole(com.wallet.secure.common.enums.UserRole role); + // ─── Security Queries ───────────────────────────────────────────────────── /** @@ -136,4 +142,4 @@ void lockAccount( @Modifying @Query("UPDATE User u SET u.emailVerified = true WHERE u.id = :id") void verifyEmail(@Param("id") UUID id); -} \ No newline at end of file +} diff --git a/src/main/java/com/wallet/secure/user/service/AdminBootstrapService.java b/src/main/java/com/wallet/secure/user/service/AdminBootstrapService.java new file mode 100644 index 0000000..0b5f5ff --- /dev/null +++ b/src/main/java/com/wallet/secure/user/service/AdminBootstrapService.java @@ -0,0 +1,88 @@ +package com.wallet.secure.user.service; + +import com.wallet.secure.common.enums.UserRole; +import com.wallet.secure.config.AdminBootstrapProperties; +import com.wallet.secure.user.entity.User; +import com.wallet.secure.user.repository.UserRepository; +import jakarta.validation.Validator; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Provisions only the first ADMIN, when explicitly enabled. + * + * OWASP A01: the public registration path remains USER-only. + * OWASP A02: the bootstrap password is BCrypt-hashed before persistence. + * OWASP A05: credentials are never written to logs or responses. + */ +@Component +@RequiredArgsConstructor +@Log4j2 +public class AdminBootstrapService implements ApplicationRunner { + + private final AdminBootstrapProperties properties; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final Validator validator; + + /** + * Runs once after Spring has created the application context. + * + *

This is provisioning, not public registration: it is an operational + * entry point used to create the first business administrator. Keeping it + * as an {@link ApplicationRunner} means it is not reachable through HTTP + * or Postman.

+ * + *

The operation is idempotent. Re-starting the application with the + * same variables does not change the existing administrator because the + * role existence check happens before persistence.

+ */ + @Override + @Transactional + public void run(ApplicationArguments args) { + if (!properties.isEnabled()) { + log.info("Admin bootstrap disabled"); + return; + } + + validateInput(); + + if (userRepository.existsByRole(UserRole.ADMIN)) { + log.info("Admin bootstrap skipped: an administrator already exists"); + return; + } + + if (userRepository.existsByEmail(properties.getEmail())) { + throw new IllegalStateException("Admin bootstrap email is already registered"); + } + + User admin = User.builder() + .email(properties.getEmail()) + .passwordHash(passwordEncoder.encode(properties.getPassword())) + .role(UserRole.ADMIN) + .emailVerified(true) + .isActive(true) + .build(); + + userRepository.save(admin); + log.info("Initial administrator provisioned successfully"); + } + + /** + * Validates only non-secret properties and password policy requirements. + * + *

Exception messages intentionally describe the violated rule without + * including the supplied password (OWASP A05/A09: avoid secret leakage in + * logs and startup diagnostics).

+ */ + private void validateInput() { + if (!validator.validate(properties).isEmpty()) { + throw new IllegalStateException("Admin bootstrap configuration is invalid"); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b223c15..7a9fac2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -79,6 +79,17 @@ jwt: header: Authorization prefix: Bearer +# FIRST ADMIN BOOTSTRAP +# Disabled by default. Enable only through private environment variables. +# ADMIN_BOOTSTRAP_ENABLED=true +# ADMIN_BOOTSTRAP_EMAIL=admin@example.test +# ADMIN_BOOTSTRAP_PASSWORD= +admin: + bootstrap: + enabled: ${ADMIN_BOOTSTRAP_ENABLED:false} + email: ${ADMIN_BOOTSTRAP_EMAIL:} + password: ${ADMIN_BOOTSTRAP_PASSWORD:} + # LOGGING logging: level: @@ -129,7 +140,7 @@ app: security: max-login-attempts: 5 # Login attempts before lockout lockout-duration-minutes: 30 # Lockout time - password-min-length: 8 + password-min-length: 12 password-require-uppercase: true password-require-lowercase: true password-require-digit: true @@ -146,4 +157,4 @@ app: display-request-duration: true # Show response time in UI try-it-out-enabled: true # Enable "Try it out" by default show-actuator: false # Don't expose actuator in Swagger - packages-to-scan: com.wallet.secure # Only scan our packages \ No newline at end of file + packages-to-scan: com.wallet.secure # Only scan our packages diff --git a/src/test/java/com/wallet/secure/user/service/AdminBootstrapServiceTest.java b/src/test/java/com/wallet/secure/user/service/AdminBootstrapServiceTest.java new file mode 100644 index 0000000..8a09715 --- /dev/null +++ b/src/test/java/com/wallet/secure/user/service/AdminBootstrapServiceTest.java @@ -0,0 +1,106 @@ +package com.wallet.secure.user.service; + +import com.wallet.secure.common.enums.UserRole; +import com.wallet.secure.config.AdminBootstrapProperties; +import com.wallet.secure.user.entity.User; +import com.wallet.secure.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.DefaultApplicationArguments; +import org.springframework.security.crypto.password.PasswordEncoder; +import jakarta.validation.Validation; +import jakarta.validation.Validator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Unit tests for the first-admin provisioning boundary. + * + *

Mockito is used instead of a Spring context so these tests verify the + * service decisions in isolation: whether persistence and BCrypt are called, + * not Hibernate or PostgreSQL behavior.

+ */ +@ExtendWith(MockitoExtension.class) +class AdminBootstrapServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + private AdminBootstrapProperties properties; + private AdminBootstrapService service; + private Validator validator; + + @BeforeEach + void setUp() { + properties = new AdminBootstrapProperties(); + validator = Validation.buildDefaultValidatorFactory().getValidator(); + service = new AdminBootstrapService(properties, userRepository, passwordEncoder, validator); + } + + @Test + /** Disabled is the safe default for normal application starts. */ + void disabledBootstrapDoesNotQueryOrCreateUser() throws Exception { + service.run(new DefaultApplicationArguments()); + + verifyNoInteractions(userRepository, passwordEncoder); + } + + @Test + /** A valid bootstrap creates a verified ADMIN and never stores plaintext. */ + void validBootstrapCreatesVerifiedAdminWithEncodedPassword() throws Exception { + properties.setEnabled(true); + properties.setEmail("admin@example.test"); + properties.setPassword("StrongPass1!"); + when(userRepository.existsByRole(UserRole.ADMIN)).thenReturn(false); + when(userRepository.existsByEmail("admin@example.test")).thenReturn(false); + when(passwordEncoder.encode("StrongPass1!")).thenReturn("$2a$12$encoded"); + + service.run(new DefaultApplicationArguments()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(captor.capture()); + User saved = captor.getValue(); + assertThat(saved.getRole()).isEqualTo(UserRole.ADMIN); + assertThat(saved.getEmail()).isEqualTo("admin@example.test"); + assertThat(saved.getPasswordHash()).isEqualTo("$2a$12$encoded"); + assertThat(saved.getEmailVerified()).isTrue(); + verify(passwordEncoder).encode("StrongPass1!"); + } + + @Test + /** Idempotency prevents a restart from creating or replacing another ADMIN. */ + void existingAdminPreventsSecondAdmin() throws Exception { + properties.setEnabled(true); + properties.setEmail("admin@example.test"); + properties.setPassword("StrongPass1!"); + when(userRepository.existsByRole(UserRole.ADMIN)).thenReturn(true); + + service.run(new DefaultApplicationArguments()); + + verify(userRepository, never()).save(any(User.class)); + verifyNoInteractions(passwordEncoder); + } + + @Test + /** Invalid input fails before repository or encoder interaction. */ + void invalidPasswordFailsWithoutPersistingOrLoggingSecret() { + properties.setEnabled(true); + properties.setEmail("admin@example.test"); + properties.setPassword("short"); + + assertThatThrownBy(() -> service.run(new DefaultApplicationArguments())) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Admin bootstrap configuration is invalid"); + verifyNoInteractions(userRepository, passwordEncoder); + } +}