Skip to content

Commit 463ca95

Browse files
feat: Add BETA role checks and include user roles
Enable method security and require BETA role for Google Drive endpoints. Explicitly allow only specific auth endpoints and add @PreAuthorize("hasRole('BETA')") to GoogleDriveController methods. Extend UserResponse with roles and canUseGoogleIntegration and update AuthMapper to populate roles and detect BETA membership. Add handler for AccessDeniedException to return 403. Update integration and unit tests to assert roles/canUseGoogleIntegration and to create a beta role/login flow for Google Drive tests.
1 parent 2f80cf2 commit 463ca95

8 files changed

Lines changed: 118 additions & 21 deletions

File tree

src/main/java/com/jobtracker/config/SecurityConfig.java

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import org.springframework.context.annotation.Configuration;
55
import org.springframework.http.HttpMethod;
66
import org.springframework.security.config.Customizer;
7+
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
78
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
89
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
910
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
@@ -15,6 +16,7 @@
1516

1617
@Configuration
1718
@EnableWebSecurity
19+
@EnableMethodSecurity
1820
public class SecurityConfig {
1921

2022
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@@ -38,15 +40,20 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
3840
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
3941
.authorizeHttpRequests(auth -> auth
4042
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
41-
.requestMatchers("/api/v1/auth/**").permitAll()
43+
.requestMatchers(
44+
"/api/v1/auth/register",
45+
"/api/v1/auth/login",
46+
"/api/v1/auth/refresh",
47+
"/api/v1/auth/forgot-password",
48+
"/api/v1/auth/reset-password",
49+
"/api/v1/auth/logout").permitAll()
4250
.requestMatchers(HttpMethod.GET, "/api/v1/google-drive/oauth/callback").permitAll()
4351
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
4452
// Actuator is served on a dedicated management port (8081) that is never
4553
// exposed to the host; security is enforced via Docker network isolation.
4654
.requestMatchers("/actuator/**").permitAll()
4755
// ROLE_USER endpoints: all remaining application APIs under /api/v1/**
48-
// (e.g. /api/v1/auth/me, /api/v1/applications/**, /api/v1/gamification/**,
49-
// /api/v1/dashboard/**, /api/v1/account/**).
56+
// (including /api/v1/auth/me and /api/v1/auth/me/**).
5057
.requestMatchers("/api/v1/**").hasRole("USER")
5158
.anyRequest().authenticated())
5259
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)

src/main/java/com/jobtracker/controller/GoogleDriveController.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import jakarta.validation.Valid;
1414
import org.springframework.http.HttpStatus;
1515
import org.springframework.http.ResponseEntity;
16+
import org.springframework.security.access.prepost.PreAuthorize;
1617
import org.springframework.web.bind.annotation.*;
1718

1819
import java.io.IOException;
@@ -36,6 +37,7 @@ public GoogleDriveController(GoogleDriveOAuthService googleDriveOAuthService, Go
3637
responses = @ApiResponse(responseCode = "200", description = "Authorization URL generated",
3738
content = @Content(schema = @Schema(implementation = GoogleDriveOAuthStartResponse.class)))
3839
)
40+
@PreAuthorize("hasRole('BETA')")
3941
@PostMapping("/oauth/start")
4042
public ResponseEntity<GoogleDriveOAuthStartResponse> startOauth() {
4143
return ResponseEntity.ok(googleDriveOAuthService.startAuthorization());
@@ -55,39 +57,45 @@ public void oauthCallback(@RequestParam(required = false) String state,
5557
responses = @ApiResponse(responseCode = "200", description = "Current Google Drive integration status",
5658
content = @Content(schema = @Schema(implementation = GoogleDriveStatusResponse.class)))
5759
)
60+
@PreAuthorize("hasRole('BETA')")
5861
@GetMapping("/status")
5962
public ResponseEntity<GoogleDriveStatusResponse> getStatus() {
6063
return ResponseEntity.ok(googleDriveService.getStatus());
6164
}
6265

6366
@Operation(summary = "Disconnect Google Drive")
67+
@PreAuthorize("hasRole('BETA')")
6468
@DeleteMapping("/connection")
6569
public ResponseEntity<MessageResponse> disconnect() {
6670
return ResponseEntity.ok(googleDriveOAuthService.disconnect());
6771
}
6872

6973
@Operation(summary = "Update Google Drive root folder")
74+
@PreAuthorize("hasRole('BETA')")
7075
@PutMapping("/root-folder")
7176
public ResponseEntity<GoogleDriveStatusResponse> updateRootFolder(
7277
@Valid @RequestBody GoogleDriveRootFolderRequest request) {
7378
return ResponseEntity.ok(googleDriveService.updateRootFolder(request));
7479
}
7580

7681
@Operation(summary = "Register a Google Docs base resume")
82+
@PreAuthorize("hasRole('BETA')")
7783
@PostMapping("/base-resumes")
7884
public ResponseEntity<GoogleDriveBaseResumeResponse> addBaseResume(
7985
@Valid @RequestBody GoogleDriveBaseResumeRequest request) {
8086
return ResponseEntity.status(HttpStatus.CREATED).body(googleDriveService.addBaseResume(request));
8187
}
8288

8389
@Operation(summary = "Delete a configured base resume")
90+
@PreAuthorize("hasRole('BETA')")
8491
@DeleteMapping("/base-resumes/{baseResumeId}")
8592
public ResponseEntity<MessageResponse> deleteBaseResume(@PathVariable UUID baseResumeId) {
8693
googleDriveService.deleteBaseResume(baseResumeId);
8794
return ResponseEntity.ok(new MessageResponse("Base resume deleted successfully"));
8895
}
8996

9097
@Operation(summary = "Copy a base resume into an application folder")
98+
@PreAuthorize("hasRole('BETA')")
9199
@PostMapping("/applications/{applicationId}/resume-copies")
92100
public ResponseEntity<GoogleDriveResumeCopyResponse> copyBaseResume(
93101
@PathVariable UUID applicationId,

src/main/java/com/jobtracker/dto/auth/UserResponse.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import io.swagger.v3.oas.annotations.media.Schema;
44

55
import java.time.LocalTime;
6+
import java.util.Set;
67
import java.util.UUID;
78

89
@Schema(description = "Authenticated user profile")
@@ -14,5 +15,9 @@ public record UserResponse(
1415
@Schema(description = "User email address", example = "john@example.com")
1516
String email,
1617
@Schema(description = "Preferred daily reminder time", example = "19:00:00")
17-
LocalTime reminderTime
18+
LocalTime reminderTime,
19+
@Schema(description = "Granted application roles", example = "[\"USER\", \"BETA\"]")
20+
Set<String> roles,
21+
@Schema(description = "Whether the user can access Google integration features", example = "true")
22+
boolean canUseGoogleIntegration
1823
) {}

src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import org.slf4j.Logger;
55
import org.slf4j.LoggerFactory;
66
import org.springframework.security.authentication.BadCredentialsException;
7+
import org.springframework.security.access.AccessDeniedException;
78
import org.springframework.security.core.AuthenticationException;
89
import org.springframework.http.HttpStatus;
910
import org.springframework.http.ResponseEntity;
@@ -62,6 +63,12 @@ public ResponseEntity<Map<String, Object>> handleRateLimitExceeded(RequestNotPer
6263
return buildResponse(HttpStatus.TOO_MANY_REQUESTS, "Too many requests. Please try again later.");
6364
}
6465

66+
@ExceptionHandler(AccessDeniedException.class)
67+
public ResponseEntity<Map<String, Object>> handleAccessDenied(AccessDeniedException ex) {
68+
log.warn("event=ACCESS_DENIED message={}", ex.getMessage());
69+
return buildResponse(HttpStatus.FORBIDDEN, "Access denied");
70+
}
71+
6572
@ExceptionHandler(MethodArgumentNotValidException.class)
6673
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
6774
Map<String, String> fieldErrors = new HashMap<>();

src/main/java/com/jobtracker/mapper/AuthMapper.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,26 @@
22

33
import com.jobtracker.dto.auth.UserResponse;
44
import com.jobtracker.entity.User;
5+
import com.jobtracker.entity.enums.RoleName;
56
import org.springframework.stereotype.Component;
67

8+
import java.util.LinkedHashSet;
9+
710
@Component
811
public class AuthMapper {
912

1013
public UserResponse toUserResponse(User user) {
11-
return new UserResponse(user.getId(), user.getName(), user.getEmail(), user.getReminderTime());
14+
LinkedHashSet<String> roles = user.getRoles().stream()
15+
.map(role -> role.getName().name())
16+
.sorted()
17+
.collect(LinkedHashSet::new, LinkedHashSet::add, LinkedHashSet::addAll);
18+
19+
return new UserResponse(
20+
user.getId(),
21+
user.getName(),
22+
user.getEmail(),
23+
user.getReminderTime(),
24+
roles,
25+
roles.contains(RoleName.BETA.name()));
1226
}
1327
}

src/test/java/com/jobtracker/integration/AuthControllerIT.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ void register_shouldReturn201_setRefreshTokenCookie_andReturnAccessToken() throw
6161
.andExpect(status().isCreated())
6262
.andExpect(jsonPath("$.accessToken").isNotEmpty())
6363
.andExpect(jsonPath("$.user.email").value("register@example.com"))
64+
.andExpect(jsonPath("$.user.roles[0]").value("USER"))
65+
.andExpect(jsonPath("$.user.canUseGoogleIntegration").value(false))
6466
// Refresh token should NOT be in JSON body (now in HttpOnly cookie)
6567
.andExpect(jsonPath("$.refreshToken").doesNotExist())
6668
.andReturn();
@@ -121,6 +123,7 @@ void login_shouldReturn200_setRefreshTokenCookie_andReturnAccessToken() throws E
121123
.andExpect(status().isOk())
122124
.andExpect(jsonPath("$.accessToken").isNotEmpty())
123125
.andExpect(jsonPath("$.user.email").value("login@example.com"))
126+
.andExpect(jsonPath("$.user.canUseGoogleIntegration").value(false))
124127
.andExpect(jsonPath("$.refreshToken").doesNotExist())
125128
.andReturn();
126129

@@ -240,7 +243,9 @@ void me_shouldReturn200_whenAuthenticated() throws Exception {
240243
mockMvc.perform(get("/api/v1/auth/me")
241244
.header("Authorization", "Bearer " + auth.accessToken()))
242245
.andExpect(status().isOk())
243-
.andExpect(jsonPath("$.email").value("me@example.com"));
246+
.andExpect(jsonPath("$.email").value("me@example.com"))
247+
.andExpect(jsonPath("$.roles[0]").value("USER"))
248+
.andExpect(jsonPath("$.canUseGoogleIntegration").value(false));
244249
}
245250

246251
@Test

src/test/java/com/jobtracker/integration/GoogleDriveControllerIT.java

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@
77
import com.jobtracker.entity.GoogleDriveBaseResume;
88
import com.jobtracker.entity.GoogleDriveConnection;
99
import com.jobtracker.entity.JobApplication;
10+
import com.jobtracker.entity.Role;
11+
import com.jobtracker.entity.User;
12+
import com.jobtracker.entity.enums.RoleName;
1013
import com.jobtracker.repository.ApplicationRepository;
1114
import com.jobtracker.repository.GoogleDriveBaseResumeRepository;
1215
import com.jobtracker.repository.GoogleDriveConnectionRepository;
1316
import com.jobtracker.repository.GoogleDriveOAuthStateRepository;
1417
import com.jobtracker.repository.PasswordResetTokenRepository;
1518
import com.jobtracker.repository.RefreshTokenRepository;
19+
import com.jobtracker.repository.RoleRepository;
1620
import com.jobtracker.repository.UserAchievementRepository;
1721
import com.jobtracker.repository.UserGamificationRepository;
1822
import com.jobtracker.repository.UserRepository;
@@ -32,6 +36,7 @@
3236
import java.util.HashMap;
3337
import java.util.Map;
3438
import java.util.Optional;
39+
import java.util.Set;
3540
import java.util.UUID;
3641

3742
import static org.assertj.core.api.Assertions.assertThat;
@@ -56,9 +61,11 @@ class GoogleDriveControllerIT extends AbstractIntegrationTest {
5661
@Autowired private GoogleDriveConnectionRepository googleDriveConnectionRepository;
5762
@Autowired private GoogleDriveBaseResumeRepository googleDriveBaseResumeRepository;
5863
@Autowired private GoogleDriveOAuthStateRepository googleDriveOAuthStateRepository;
64+
@Autowired private RoleRepository roleRepository;
5965
@Autowired private FakeGoogleDriveApiClient googleDriveApiClient;
6066

61-
private String accessToken;
67+
private String betaAccessToken;
68+
private String nonBetaAccessToken;
6269

6370
@BeforeEach
6471
void setUp() throws Exception {
@@ -79,20 +86,52 @@ void setUp() throws Exception {
7986
.andReturn();
8087

8188
AuthResponse auth = objectMapper.readValue(result.getResponse().getContentAsString(), AuthResponse.class);
82-
accessToken = auth.accessToken();
89+
nonBetaAccessToken = auth.accessToken();
90+
91+
User user = userRepository.findByEmail("driveuser@example.com").orElseThrow();
92+
Role betaRole = roleRepository.findByName(RoleName.BETA)
93+
.orElseGet(() -> {
94+
Role role = new Role();
95+
role.setName(RoleName.BETA);
96+
return roleRepository.save(role);
97+
});
98+
user.setRoles(Set.of(
99+
user.getRoles().stream().findFirst().orElseThrow(),
100+
betaRole));
101+
userRepository.save(user);
102+
103+
MvcResult loginResult = mockMvc.perform(post("/api/v1/auth/login")
104+
.contentType(MediaType.APPLICATION_JSON)
105+
.content("""
106+
{
107+
"email": "driveuser@example.com",
108+
"password": "pass1234"
109+
}
110+
"""))
111+
.andReturn();
112+
113+
AuthResponse betaAuth = objectMapper.readValue(loginResult.getResponse().getContentAsString(), AuthResponse.class);
114+
betaAccessToken = betaAuth.accessToken();
83115
}
84116

85117
@Test
86118
void startOauth_shouldReturnAuthorizationUrl() throws Exception {
87119
mockMvc.perform(post("/api/v1/google-drive/oauth/start")
88-
.header("Authorization", "Bearer " + accessToken))
120+
.header("Authorization", "Bearer " + betaAccessToken))
89121
.andExpect(status().isOk())
90122
.andExpect(jsonPath("$.authorizationUrl").value(org.hamcrest.Matchers.containsString("https://accounts.google.com/o/oauth2/v2/auth")))
91123
.andExpect(jsonPath("$.state").isNotEmpty())
92124
.andExpect(jsonPath("$.redirectUri").value("http://localhost:8080/api/v1/google-drive/oauth/callback"))
93125
.andExpect(jsonPath("$.scopes[0]").value("https://www.googleapis.com/auth/drive"));
94126
}
95127

128+
@Test
129+
void startOauth_shouldReturn403_whenUserDoesNotHaveBetaRole() throws Exception {
130+
mockMvc.perform(post("/api/v1/google-drive/oauth/start")
131+
.header("Authorization", "Bearer " + nonBetaAccessToken))
132+
.andExpect(status().isForbidden());
133+
}
134+
96135
@Test
97136
void oauthCallback_shouldPersistConnectionAndRedirectToFrontend() throws Exception {
98137
googleDriveApiClient.tokens = new GoogleDriveApiClient.OAuthTokens(
@@ -105,7 +144,7 @@ void oauthCallback_shouldPersistConnectionAndRedirectToFrontend() throws Excepti
105144
new GoogleDriveApiClient.GoogleDriveAccountProfile("perm-123", "connected@example.com", "Drive User");
106145

107146
MvcResult startResult = mockMvc.perform(post("/api/v1/google-drive/oauth/start")
108-
.header("Authorization", "Bearer " + accessToken))
147+
.header("Authorization", "Bearer " + betaAccessToken))
109148
.andExpect(status().isOk())
110149
.andReturn();
111150

@@ -135,7 +174,7 @@ void updateRootFolder_shouldReturnUpdatedStatus() throws Exception {
135174
));
136175

137176
mockMvc.perform(put("/api/v1/google-drive/root-folder")
138-
.header("Authorization", "Bearer " + accessToken)
177+
.header("Authorization", "Bearer " + betaAccessToken)
139178
.contentType(MediaType.APPLICATION_JSON)
140179
.content("{\"folderIdOrUrl\":\"folder-123\"}"))
141180
.andExpect(status().isOk())
@@ -150,7 +189,7 @@ void disconnect_shouldRemoveConnectionAndReturnMessage() throws Exception {
150189
assertThat(googleDriveConnectionRepository.findAll()).hasSize(1);
151190

152191
mockMvc.perform(delete("/api/v1/google-drive/connection")
153-
.header("Authorization", "Bearer " + accessToken))
192+
.header("Authorization", "Bearer " + betaAccessToken))
154193
.andExpect(status().isOk())
155194
.andExpect(jsonPath("$.message").value("Google Drive connection removed"));
156195

@@ -160,7 +199,7 @@ void disconnect_shouldRemoveConnectionAndReturnMessage() throws Exception {
160199
@Test
161200
void disconnect_shouldSucceedEvenWithNoExistingConnection() throws Exception {
162201
mockMvc.perform(delete("/api/v1/google-drive/connection")
163-
.header("Authorization", "Bearer " + accessToken))
202+
.header("Authorization", "Bearer " + betaAccessToken))
164203
.andExpect(status().isOk())
165204
.andExpect(jsonPath("$.message").value("Google Drive connection removed"));
166205
}
@@ -177,7 +216,7 @@ void addBaseResume_shouldPersistResumeAndReturn201() throws Exception {
177216
));
178217

179218
mockMvc.perform(post("/api/v1/google-drive/base-resumes")
180-
.header("Authorization", "Bearer " + accessToken)
219+
.header("Authorization", "Bearer " + betaAccessToken)
181220
.contentType(MediaType.APPLICATION_JSON)
182221
.content("{\"documentIdOrUrl\":\"doc-abc\"}"))
183222
.andExpect(status().isCreated())
@@ -200,7 +239,7 @@ void addBaseResume_shouldRejectNonGoogleDocsFile() throws Exception {
200239
));
201240

202241
mockMvc.perform(post("/api/v1/google-drive/base-resumes")
203-
.header("Authorization", "Bearer " + accessToken)
242+
.header("Authorization", "Bearer " + betaAccessToken)
204243
.contentType(MediaType.APPLICATION_JSON)
205244
.content("{\"documentIdOrUrl\":\"pdf-file\"}"))
206245
.andExpect(status().isBadRequest());
@@ -213,7 +252,7 @@ void deleteBaseResume_shouldRemoveResumeAndReturn200() throws Exception {
213252
googleDriveBaseResumeRepository.save(resume);
214253

215254
mockMvc.perform(delete("/api/v1/google-drive/base-resumes/" + resume.getId())
216-
.header("Authorization", "Bearer " + accessToken))
255+
.header("Authorization", "Bearer " + betaAccessToken))
217256
.andExpect(status().isOk())
218257
.andExpect(jsonPath("$.message").value("Base resume deleted successfully"));
219258

@@ -223,7 +262,7 @@ void deleteBaseResume_shouldRemoveResumeAndReturn200() throws Exception {
223262
@Test
224263
void deleteBaseResume_shouldReturn404ForUnknownId() throws Exception {
225264
mockMvc.perform(delete("/api/v1/google-drive/base-resumes/" + UUID.randomUUID())
226-
.header("Authorization", "Bearer " + accessToken))
265+
.header("Authorization", "Bearer " + betaAccessToken))
227266
.andExpect(status().isNotFound());
228267
}
229268

@@ -249,7 +288,7 @@ void copyResume_shouldCreateFolderAndCopyDocument() throws Exception {
249288
));
250289

251290
mockMvc.perform(post("/api/v1/google-drive/applications/" + application.getId() + "/resume-copies")
252-
.header("Authorization", "Bearer " + accessToken)
291+
.header("Authorization", "Bearer " + betaAccessToken)
253292
.contentType(MediaType.APPLICATION_JSON)
254293
.content("{\"baseResumeId\":\"" + resume.getId() + "\"}"))
255294
.andExpect(status().isCreated())
@@ -273,7 +312,7 @@ void copyResume_shouldReturn400WhenNoRootFolderConfigured() throws Exception {
273312
application = applicationRepository.save(application);
274313

275314
mockMvc.perform(post("/api/v1/google-drive/applications/" + application.getId() + "/resume-copies")
276-
.header("Authorization", "Bearer " + accessToken)
315+
.header("Authorization", "Bearer " + betaAccessToken)
277316
.contentType(MediaType.APPLICATION_JSON)
278317
.content("{\"baseResumeId\":\"" + resume.getId() + "\"}"))
279318
.andExpect(status().isBadRequest());

0 commit comments

Comments
 (0)