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. + * + *
} authorities (for any role-based rules a consumer adds);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