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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,27 @@ The library major aligns with the Spring Boot major: `4.x.y` targets Spring Boot
## [Unreleased]

### Added
- **First-admin bootstrap** (ADR 0001) — opt-in, property-driven provisioning so
a fresh database can reach a usable dashboard without a permanent backdoor.
`devslab.kit.bootstrap.*` (OFF by default) idempotently creates a tenant, a
`PLATFORM_ADMIN` role with the full `admin.*` permission set, and one admin
user on first boot. A blank password generates a strong random one logged
once; a prod safety pin refuses a weak password under a `prod`/`production`
profile.
- **Forced password change** — `must_change_password` flag (`V11`) on the user
account, surfaced through `CurrentUser`, the JWT claim, and the login
response. Self-service `POST /admin/api/v1/auth/change-password` verifies the
old password, sets the new one, clears the flag, and re-issues a token.
- **Bootstrap status probe** — unauthenticated `GET /admin/api/v1/bootstrap/status`
returning `{ initialized: boolean }`, the branch point for a future guided
first-run / setup wizard (ADR 0001 §6).

### Changed
- `sample-app` switched off its `SampleSeedRunner` onto the starter's
`devslab.kit.bootstrap.*` runner (local-dev shape: `admin/admin`,
`must-change-password=false`).

### Added (initial scaffold)
- Initial project scaffold (Spring Boot 4 + Java 21 + Gradle).
- Base dependencies: Spring Web MVC, Spring Security, Spring Data JPA, Spring Data Redis,
Flyway (PostgreSQL), Spring Boot Actuator, GraalVM Native, Testcontainers (PostgreSQL + Redis),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ public final class AdminApiPaths {
public static final String MENUS = BASE + "/menus";
public static final String AUDIT_LOGS = BASE + "/audit-logs";

/** Unauthenticated: a setup wizard reads this before any account exists. */
public static final String BOOTSTRAP_STATUS = BASE + "/bootstrap/status";

private AdminApiPaths() {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package kr.devslab.kit.admin.bootstrap;

import kr.devslab.kit.admin.AdminApiPaths;
import kr.devslab.kit.identity.core.repository.JpaPlatformUserAccountRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* Reports whether the platform has been provisioned yet (ADR 0001).
*
* <p>Deliberately <strong>unauthenticated</strong> — a guided first-run / setup
* wizard has to ask this <em>before</em> any account exists, i.e. before anyone
* could possibly hold a token. It leaks only a single boolean (does at least one
* account exist), never any account detail, so exposing it pre-auth is safe and
* is whitelisted in {@code AdminSecurityConfig}.
*/
@RestController
@RequestMapping(AdminApiPaths.BASE + "/bootstrap")
public class BootstrapStatusController {

private final JpaPlatformUserAccountRepository userRepository;

public BootstrapStatusController(JpaPlatformUserAccountRepository userRepository) {
this.userRepository = userRepository;
}

@GetMapping("/status")
public BootstrapStatusResponse status() {
return new BootstrapStatusResponse(userRepository.count() > 0);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package kr.devslab.kit.admin.bootstrap;

/**
* Whether the platform has been provisioned with at least one account.
*
* <p>{@code initialized == false} means a fresh, un-provisioned instance — no
* one can log in yet. A guided first-run / setup wizard branches on this to
* decide whether to show a "create the first administrator" form (ADR 0001).
*/
public record BootstrapStatusResponse(boolean initialized) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ public SecurityFilterChain devslabKitAdminSecurityFilterChain(
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(reg -> reg
.requestMatchers(HttpMethod.POST, AdminApiPaths.BASE + "/auth/login").permitAll()
// Setup-wizard probe — must answer before any account
// exists, so it cannot require authentication. Leaks
// only a single boolean (see BootstrapStatusController).
.requestMatchers(HttpMethod.GET, AdminApiPaths.BOOTSTRAP_STATUS).permitAll()
.requestMatchers(AdminApiPaths.BASE + "/**").authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package kr.devslab.kit.sample;

import static org.assertj.core.api.Assertions.assertThat;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Import;

/**
* Verifies the bootstrap-status endpoint (ADR 0001) over the real HTTP stack:
* it must answer {@code 200 { "initialized": ... }} <em>without</em> a token,
* since a setup wizard calls it before any account exists.
*
* <p>Driven with the JDK's {@link HttpClient} rather than Spring's RestTemplate
* / TestRestTemplate / MockMvc helpers — Spring Boot 4 relocated all three, and
* the JDK client has no such churn, doesn't throw on 4xx, and needs no extra
* test dependency.
*
* <p>Runs against the sample-app, whose bootstrap runner provisioned the admin
* user, so {@code initialized} is {@code true} here.
*/
@Import(TestcontainersConfiguration.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class BootstrapStatusEndpointTests {

@LocalServerPort
private int port;

private final HttpClient http = HttpClient.newHttpClient();

private HttpResponse<String> get(String path) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + port + path))
.GET()
.build();
return http.send(request, HttpResponse.BodyHandlers.ofString());
}

@Test
void statusIsReachableWithoutAuthAndReportsInitialized() throws Exception {
HttpResponse<String> response = get("/admin/api/v1/bootstrap/status");

assertThat(response.statusCode()).isEqualTo(200);
// sample-app's bootstrap runner provisioned admin → initialized == true
assertThat(response.body()).contains("\"initialized\":true");
}

@Test
void otherAdminEndpointsStillRequireAuth() throws Exception {
// Guard against the permitAll matcher being too broad: a sibling admin
// endpoint must still reject an unauthenticated request (401/403).
HttpResponse<String> response = get("/admin/api/v1/users?tenantId=default");

assertThat(response.statusCode()).isIn(401, 403);
}
}
Loading