Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
FILE_DATA_POSTGRES=/path/to/your/data/postgres_wallet
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- [ ] `@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.
15 changes: 0 additions & 15 deletions database/07-seed.sql

This file was deleted.

7 changes: 4 additions & 3 deletions database/README.md
Original file line number Diff line number Diff line change
@@ -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.

---

Expand All @@ -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 |

---

Expand Down Expand Up @@ -172,4 +173,4 @@ docker compose --profile tools up -d

# pgAdmin: http://localhost:5050
# PostgreSQL: localhost:5432
```
```
6 changes: 5 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -82,4 +86,4 @@ services:

networks:
wallet-network:
driver: bridge
driver: bridge
10 changes: 5 additions & 5 deletions docs/IMPLEMENTATION-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ public interface UserRepository extends JpaRepository<User, UUID> {
*/
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 ─────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* <p>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.</p>
*/
@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.
*
* <p>Exception messages intentionally describe the violated rule without
* including the supplied password (OWASP A05/A09: avoid secret leakage in
* logs and startup diagnostics).</p>
*/
private void validateInput() {
if (!validator.validate(properties).isEmpty()) {
throw new IllegalStateException("Admin bootstrap configuration is invalid");
}
}
}
15 changes: 13 additions & 2 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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=<private value>
admin:
bootstrap:
enabled: ${ADMIN_BOOTSTRAP_ENABLED:false}
email: ${ADMIN_BOOTSTRAP_EMAIL:}
password: ${ADMIN_BOOTSTRAP_PASSWORD:}

# LOGGING
logging:
level:
Expand Down Expand Up @@ -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
Expand All @@ -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
packages-to-scan: com.wallet.secure # Only scan our packages
Loading
Loading