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
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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.
*
* <p>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.
*
* <p>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
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 <em>and effective permissions</em>, so
* {@link AdminSecurityConfig}'s {@code hasAuthority("admin.*")} rules enforce real RBAC.
*
* <p>Permissions are resolved <strong>per request</strong> 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.
*
* <ul>
* <li>roles → {@code ROLE_<code>} authorities (for any role-based rules a consumer adds);</li>
* <li>permissions → the raw {@code admin.x.y} code as the authority, matched by
* {@code hasAuthority(...)} in the admin security chain.</li>
* </ul>
*/
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
Expand All @@ -39,9 +62,13 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
}

private void authenticate(CurrentUser user, HttpServletRequest request) {
List<SimpleGrantedAuthority> authorities = user.roles().stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r))
.toList();
List<SimpleGrantedAuthority> 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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <ul>
* <li><b>allow</b> — the bootstrap admin (seeded with every {@code admin.*} permission on
* {@code PLATFORM_ADMIN}) logs in and a protected read endpoint returns 200;</li>
* <li><b>deny</b> — an authenticated caller holding <em>no</em> grants gets 403 on the same
* endpoint (so authentication alone is not enough — the permission is enforced).</li>
* </ul>
*
* The unauthenticated case (401/403 with no token) is covered by
* {@link BootstrapStatusEndpointTests#otherAdminEndpointsStillRequireAuth()}.
*
* <p>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<String> 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<String> response = getWithToken(USERS, token);

assertThat(response.statusCode()).isEqualTo(403);
}

private String login(String loginId, String password) throws Exception {
HttpResponse<String> 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<String> 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<String> 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());
}
}
Loading