diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/security/AdminSecurityConfig.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/security/AdminSecurityConfig.java index c004756..0efb545 100644 --- a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/security/AdminSecurityConfig.java +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/security/AdminSecurityConfig.java @@ -1,7 +1,7 @@ package kr.devslab.kit.admin.security; +import kr.devslab.kit.access.core.repository.JpaPlatformPermissionRepository; import kr.devslab.kit.admin.AdminApiPaths; -import kr.devslab.kit.identity.AuthTokenService; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -11,13 +11,36 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +/** + * Ships the admin REST API with real RBAC already wired: every {@code /admin/api/v1/**} + * endpoint is mapped to the {@code admin.*} permission it requires, enforced by Spring + * Security against the authorities the {@link JwtAuthenticationFilter} resolves for the + * caller. A consumer adds the starter and gets an authorization-enforced admin API with no + * configuration of their own; the first-admin bootstrap seeds all {@code admin.*} permissions + * onto {@code PLATFORM_ADMIN}, so the seeded admin can use every endpoint immediately. + * + *

The whole authorization matrix lives here, in one auditable place, rather than scattered + * as {@code @PreAuthorize} across the controllers. The chain is scoped to {@code /admin/api/v1/**} + * and every bean is {@code @ConditionalOnMissingBean}, so a consumer can supply their own + * {@code SecurityFilterChain} or {@code JwtAuthenticationFilter} to change any of it. + * + *

Read endpoints require {@code *.read}; mutating endpoints require {@code *.write}. The + * read rules are method-scoped ({@code GET}) and declared before each resource's catch-all so + * the catch-all only matches the mutating verbs. {@code POST /auth/login} and the + * unauthenticated {@code GET bootstrap/status} are public (a setup wizard calls them before any + * account exists); everything else under the base path (e.g. self-service change-password) just + * needs authentication. + */ @Configuration public class AdminSecurityConfig { @Bean @ConditionalOnMissingBean - public JwtAuthenticationFilter jwtAuthenticationFilter(AuthTokenService tokenService) { - return new JwtAuthenticationFilter(tokenService); + public JwtAuthenticationFilter jwtAuthenticationFilter( + kr.devslab.kit.identity.AuthTokenService tokenService, + JpaPlatformPermissionRepository permissionRepository + ) { + return new JwtAuthenticationFilter(tokenService, permissionRepository); } @Bean @@ -26,17 +49,59 @@ public SecurityFilterChain devslabKitAdminSecurityFilterChain( HttpSecurity http, JwtAuthenticationFilter jwtFilter ) throws Exception { + String base = AdminApiPaths.BASE; http - .securityMatcher(AdminApiPaths.BASE + "/**") + .securityMatcher(base + "/**") .csrf(csrf -> csrf.disable()) .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). + // Public: setup wizard + login, before any account/permission exists. + .requestMatchers(HttpMethod.POST, base + "/auth/login").permitAll() .requestMatchers(HttpMethod.GET, AdminApiPaths.BOOTSTRAP_STATUS).permitAll() - .requestMatchers(AdminApiPaths.BASE + "/**").authenticated() + // Users + .requestMatchers(HttpMethod.GET, AdminApiPaths.USERS, AdminApiPaths.USERS + "/**") + .hasAuthority("admin.user.read") + .requestMatchers(AdminApiPaths.USERS, AdminApiPaths.USERS + "/**") + .hasAuthority("admin.user.write") + // Roles (incl. permission grants + user assignments) + .requestMatchers(HttpMethod.GET, AdminApiPaths.ROLES, AdminApiPaths.ROLES + "/**") + .hasAuthority("admin.role.read") + .requestMatchers(AdminApiPaths.ROLES, AdminApiPaths.ROLES + "/**") + .hasAuthority("admin.role.write") + // Permissions + .requestMatchers(HttpMethod.GET, AdminApiPaths.PERMISSIONS, AdminApiPaths.PERMISSIONS + "/**") + .hasAuthority("admin.permission.read") + .requestMatchers(AdminApiPaths.PERMISSIONS, AdminApiPaths.PERMISSIONS + "/**") + .hasAuthority("admin.permission.write") + // Groups (incl. memberships + role assignments) + .requestMatchers(HttpMethod.GET, AdminApiPaths.GROUPS, AdminApiPaths.GROUPS + "/**") + .hasAuthority("admin.group.read") + .requestMatchers(AdminApiPaths.GROUPS, AdminApiPaths.GROUPS + "/**") + .hasAuthority("admin.group.write") + // Menus + .requestMatchers(HttpMethod.GET, AdminApiPaths.MENUS, AdminApiPaths.MENUS + "/**") + .hasAuthority("admin.menu.read") + .requestMatchers(AdminApiPaths.MENUS, AdminApiPaths.MENUS + "/**") + .hasAuthority("admin.menu.write") + // Tenants + .requestMatchers(HttpMethod.GET, base + "/tenants", base + "/tenants/**") + .hasAuthority("admin.tenant.read") + .requestMatchers(base + "/tenants", base + "/tenants/**") + .hasAuthority("admin.tenant.write") + // Audit logs (read-only) + .requestMatchers(AdminApiPaths.AUDIT_LOGS, AdminApiPaths.AUDIT_LOGS + "/**") + .hasAuthority("admin.audit.read") + // Policies (dry-run) + .requestMatchers(base + "/policies", base + "/policies/**") + .hasAuthority("admin.policy.test") + // Diagnostics (probes) + .requestMatchers(base + "/diagnostics", base + "/diagnostics/**") + .hasAuthority("admin.diagnostics.run") + // Settings (read-only) + .requestMatchers(base + "/settings", base + "/settings/**") + .hasAuthority("admin.settings.read") + // Self-service (e.g. change-password) and anything else under the base path. + .anyRequest().authenticated() ) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/security/JwtAuthenticationFilter.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/security/JwtAuthenticationFilter.java index 2f1ba1b..07b5700 100644 --- a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/security/JwtAuthenticationFilter.java +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/security/JwtAuthenticationFilter.java @@ -5,7 +5,9 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.util.ArrayList; import java.util.List; +import kr.devslab.kit.access.core.repository.JpaPlatformPermissionRepository; import kr.devslab.kit.identity.AuthTokenService; import kr.devslab.kit.identity.CurrentUser; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -14,15 +16,36 @@ import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.web.filter.OncePerRequestFilter; +/** + * Authenticates admin-API requests from a Bearer JWT and populates the Spring + * Security context with the caller's roles and effective permissions, so + * {@link AdminSecurityConfig}'s {@code hasAuthority("admin.*")} rules enforce real RBAC. + * + *

Permissions are resolved per request from the database + * ({@link JpaPlatformPermissionRepository#findCodesForUserId} — the same effective-grant + * query, spanning direct role grants and group→role grants, that {@code PermissionChecker} + * uses) rather than read from the token. That keeps the JWT small and, more importantly, + * makes a permission grant/revocation take effect on the very next request instead of + * lingering until the token expires. + * + *

+ */ public class JwtAuthenticationFilter extends OncePerRequestFilter { private static final String AUTH_HEADER = "Authorization"; private static final String BEARER_PREFIX = "Bearer "; private final AuthTokenService tokenService; + private final JpaPlatformPermissionRepository permissionRepository; - public JwtAuthenticationFilter(AuthTokenService tokenService) { + public JwtAuthenticationFilter(AuthTokenService tokenService, + JpaPlatformPermissionRepository permissionRepository) { this.tokenService = tokenService; + this.permissionRepository = permissionRepository; } @Override @@ -39,9 +62,13 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } private void authenticate(CurrentUser user, HttpServletRequest request) { - List authorities = user.roles().stream() - .map(r -> new SimpleGrantedAuthority("ROLE_" + r)) - .toList(); + List authorities = new ArrayList<>(); + for (String role : user.roles()) { + authorities.add(new SimpleGrantedAuthority("ROLE_" + role)); + } + for (String permission : permissionRepository.findCodesForUserId(user.id().value())) { + authorities.add(new SimpleGrantedAuthority(permission)); + } var auth = new UsernamePasswordAuthenticationToken(user, null, authorities); auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(auth); diff --git a/devslab-kit-sample-app/src/test/java/kr/devslab/kit/sample/AdminRbacEnforcementTests.java b/devslab-kit-sample-app/src/test/java/kr/devslab/kit/sample/AdminRbacEnforcementTests.java new file mode 100644 index 0000000..bb05b6f --- /dev/null +++ b/devslab-kit-sample-app/src/test/java/kr/devslab/kit/sample/AdminRbacEnforcementTests.java @@ -0,0 +1,117 @@ +package kr.devslab.kit.sample; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Set; +import java.util.UUID; +import kr.devslab.kit.core.id.PublicId; +import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.core.id.UserId; +import kr.devslab.kit.identity.AuthTokenService; +import kr.devslab.kit.identity.CurrentUser; +import kr.devslab.kit.identity.UserStatus; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; + +/** + * Proves the admin REST API enforces {@code admin.*} permissions out of the box — the + * documented promise ("guarded by Spring Security and the kit's permissions") actually holds + * for a consumer that only added the starter. + * + *
    + *
  • allow — the bootstrap admin (seeded with every {@code admin.*} permission on + * {@code PLATFORM_ADMIN}) logs in and a protected read endpoint returns 200;
  • + *
  • deny — an authenticated caller holding no grants gets 403 on the same + * endpoint (so authentication alone is not enough — the permission is enforced).
  • + *
+ * + * The unauthenticated case (401/403 with no token) is covered by + * {@link BootstrapStatusEndpointTests#otherAdminEndpointsStillRequireAuth()}. + * + *

Driven with the JDK {@link HttpClient} against the real HTTP stack (no MockMvc), matching + * {@link BootstrapStatusEndpointTests}. + */ +@Import(TestcontainersConfiguration.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AdminRbacEnforcementTests { + + private static final String USERS = "/admin/api/v1/users?tenantId=default"; + private static final String LOGIN = "/admin/api/v1/auth/login"; + + @LocalServerPort + private int port; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private AuthTokenService tokenService; + + private final HttpClient http = HttpClient.newHttpClient(); + + @Test + void seededAdminHoldsEveryPermissionAndCanReadUsers() throws Exception { + String adminToken = login("admin", "admin"); + + HttpResponse response = getWithToken(USERS, adminToken); + + assertThat(response.statusCode()).isEqualTo(200); + } + + @Test + void authenticatedCallerWithoutThePermissionIsForbidden() throws Exception { + // A syntactically valid, correctly signed token (issued by the app's own + // AuthTokenService) for a principal that holds no role/group grants — so the + // per-request permission resolution finds nothing and the hasAuthority("admin.user.read") + // rule denies. The user id is random, hence zero grants in the database. + CurrentUser noGrants = new CurrentUser( + UserId.of(UUID.randomUUID()), + PublicId.of("pub-nobody"), + TenantId.of("default"), + "nobody", + UserStatus.ACTIVE, + Set.of(), + false); + String token = tokenService.issue(noGrants).value(); + + HttpResponse response = getWithToken(USERS, token); + + assertThat(response.statusCode()).isEqualTo(403); + } + + private String login(String loginId, String password) throws Exception { + HttpResponse response = post(LOGIN, loginBody(loginId, password)); + assertThat(response.statusCode()).isEqualTo(200); + return objectMapper.readTree(response.body()).get("token").asText(); + } + + private static String loginBody(String loginId, String password) { + return "{\"tenantId\":\"default\",\"loginId\":\"" + loginId + "\",\"rawPassword\":\"" + password + "\"}"; + } + + private HttpResponse post(String path, String json) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + port + path)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(json)) + .build(); + return http.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse getWithToken(String path, String token) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + port + path)) + .header("Authorization", "Bearer " + token) + .GET() + .build(); + return http.send(request, HttpResponse.BodyHandlers.ofString()); + } +}