Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a8770ec
Add encryption configuration and service: implement TextEncryptor bea…
codebyNorthsteep Apr 20, 2026
0931c4f
Enhance EncryptionService: add encrypt, decrypt, and maskLastFour met…
codebyNorthsteep Apr 20, 2026
97727e2
Move key-values to .env file
codebyNorthsteep Apr 20, 2026
76fd830
Integrate EncryptionService into EmploymentFormService and StaffServi…
codebyNorthsteep Apr 20, 2026
ee96fcb
Add encryption for SSNs in EmploymentForm and update related services…
codebyNorthsteep Apr 21, 2026
3e15602
Merge branch 'main' into enhancement/encryptSSN
codebyNorthsteep Apr 21, 2026
a4694ba
Add `@ActiveProfiles("test")` to security tests and improve HR access…
codebyNorthsteep Apr 21, 2026
0b2b5c1
Merge branch 'main' into enhancement/encryptSSN
codebyNorthsteep Apr 21, 2026
243ebcd
Merge branch 'main' into enhancement/encryptSSN
codebyNorthsteep Apr 22, 2026
5f198ad
Enhance SSN handling: update staff retrieval methods to improve secur…
codebyNorthsteep Apr 22, 2026
37b531d
Merge branch 'main' into enhancement/encryptSSN
codebyNorthsteep Apr 22, 2026
b6de479
Enhance SSN handling: update staff retrieval methods to improve secur…
codebyNorthsteep Apr 22, 2026
1fba67b
Enhance SSN handling: update staff retrieval methods to improve secur…
codebyNorthsteep Apr 22, 2026
27722a0
Enhance SSN handling: refactor employment form creation and update me…
codebyNorthsteep Apr 22, 2026
20b3179
Enhance SSN handling: refactor employment form creation and update me…
codebyNorthsteep Apr 22, 2026
ec2696f
Merge branch 'main' into enhancement/encryptSSN
codebyNorthsteep Apr 22, 2026
d9ff110
Merge branch 'main' into enhancement/encryptSSN
codebyNorthsteep Apr 23, 2026
95fc28f
Refactor EmploymentFormController to simplify staff type checking
codebyNorthsteep Apr 23, 2026
b997563
Merge branch 'main' into enhancement/encryptSSN
codebyNorthsteep Apr 23, 2026
70d8635
Refactor EmploymentFormController and StaffService to use @Authentica…
codebyNorthsteep Apr 26, 2026
6c47873
Enhance SecurityIntegrationTests to include authentication for HR rol…
codebyNorthsteep Apr 26, 2026
3a3f38f
Merge branch 'main' into enhancement/encryptSSN
codebyNorthsteep Apr 26, 2026
8cc701b
Refactor EmploymentFormController logging and improve SSN masking log…
codebyNorthsteep Apr 26, 2026
ec8d40b
Update exception message in StaffService to clarify input validation …
codebyNorthsteep Apr 26, 2026
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
14 changes: 9 additions & 5 deletions src/main/java/org/example/cyberwatch/config/DataInitializer.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.example.cyberwatch.config;

import org.example.cyberwatch.config.security.EncryptionService;
import org.example.cyberwatch.features.staff.model.Staff;
import org.example.cyberwatch.features.staff.repository.StaffRepository;
import org.example.cyberwatch.shared.model.enums.Department;
Expand All @@ -14,10 +15,12 @@
public class DataInitializer implements CommandLineRunner {

private final StaffRepository staffRepository;
private final EncryptionService encryptionService;
private final PasswordEncoder passwordEncoder;

public DataInitializer(StaffRepository staffRepository, PasswordEncoder passwordEncoder) {
public DataInitializer(StaffRepository staffRepository, EncryptionService encryptionService, PasswordEncoder passwordEncoder) {
this.staffRepository = staffRepository;
this.encryptionService = encryptionService;
this.passwordEncoder = passwordEncoder;
}

Expand All @@ -28,15 +31,16 @@ public void run(String... args) {
}

staffRepository.saveAll(List.of(
createStaff("19900101-0101", "Alice", "Andersson", "alice@cyberwatch.local", "0701111111", Role.HR, Department.BACKEND, "testPass123"),
createStaff("19880505-0505", "Bob", "Berg", "bob@cyberwatch.local", "0702222222", Role.CEO, Department.DEVOPS, "testPass1234"),
createStaff("19770707-0707", "Carla", "Carlsson", "carla@cyberwatch.local", "0703333333", Role.CTO, Department.HR, "testPass12345")
createStaff("19900101-0101", "Eric", "Thilen", "ericthilen2003@gmail.com", "0701111111", Role.ADMIN, Department.BACKEND, "testPass123"),
createStaff("19900505-0505", "Caroline", "Nordbrandt", "nordbrandtcaroline@gmail.com", "0702222222", Role.ADMIN, Department.DEVOPS, "testPass1234"),
createStaff("19900707-0707", "Alice", "Wersen", "alicewersen@hotmail.com", "0703333333", Role.ADMIN, Department.HR, "testPass12345"),
createStaff("19900909-0909", "Younes", "Lamia", "younescool94@gmail.com", "0704444444", Role.ADMIN, Department.FRONTEND, "testPass1234")
));
}

private Staff createStaff(String ssn, String firstName, String lastName, String email, String phone, Role role, Department department, String rawPassword) {
Staff staff = new Staff();
staff.setSocialSecurityNumber(ssn);
staff.setSocialSecurityNumber(encryptionService.encrypt(ssn));
staff.setFirstName(firstName);
staff.setLastName(lastName);
staff.setEmail(email);
Expand Down
22 changes: 22 additions & 0 deletions src/main/java/org/example/cyberwatch/config/EncryptionConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.example.cyberwatch.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.encrypt.Encryptors;
import org.springframework.security.crypto.encrypt.TextEncryptor;

@Configuration
public class EncryptionConfig {

@Value("${app.encryption.key}")
private String encryptionKey;

@Value("${app.encryption.salt}")
private String salt;

@Bean
public TextEncryptor textEncryptor() {
return Encryptors.text(encryptionKey, salt);
}
Comment thread
codebyNorthsteep marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package org.example.cyberwatch.config.security;

import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.encrypt.TextEncryptor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class EncryptionService {

private final TextEncryptor textEncryptor;

public String encrypt(String data) {
return textEncryptor.encrypt(data);
}

public String decrypt(String data) {
return textEncryptor.decrypt(data);
}

public String maskLastFour(String encrypted) {
if (encrypted == null || encrypted.isBlank()) {
return "****";
}
String decrypted = textEncryptor.decrypt(encrypted);
if (decrypted.length() <= 4) {
return "****";
}
return decrypted.substring(0, decrypted.length() - 4) + "****";
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
import org.example.cyberwatch.features.form.dto.EmploymentFormDTO;
import org.example.cyberwatch.features.form.dto.UpdateEmploymentDTO;
import org.example.cyberwatch.features.form.service.EmploymentFormService;
import org.example.cyberwatch.features.staff.model.Staff;
import org.example.cyberwatch.shared.model.enums.ApprovalStatus;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import java.util.List;
Expand All @@ -28,25 +28,18 @@ public EmploymentFormController(EmploymentFormService employmentFormService) {

@PostMapping("/employment")
public ResponseEntity<EmploymentFormDTO> createEmploymentForm(@Valid @RequestBody CreateEmploymentDTO dto,
Authentication authentication) {
Object principal = authentication.getPrincipal();
if (!(principal instanceof org.example.cyberwatch.features.staff.model.Staff staff)) {
throw new AccessDeniedException("Principal must be a Staff object");
}
logger.info("Creating employment form for HR staff: {}", staff.getEmail());
@AuthenticationPrincipal Staff staff) {
logger.info("Creating employment form by HR staff: {}", staff.getEmail());
EmploymentFormDTO createdForm = employmentFormService.createForm(dto, staff);
return ResponseEntity.status(HttpStatus.CREATED).body(createdForm);
}


//Change returntype when emailservice is implemented
@PostMapping("/{id}/approve")
//Change returntype when emailservice is implemented
public ResponseEntity<String> approveForm(@PathVariable Long id, Authentication authentication) {
Object principal = authentication.getPrincipal();
if (!(principal instanceof org.example.cyberwatch.features.staff.model.Staff staff)) {
throw new AccessDeniedException("Principal must be a Staff object");
}
logger.info("Creating employment form with approval from staff: {}", staff.getEmail());
public ResponseEntity<String> approveForm(@PathVariable Long id, @AuthenticationPrincipal Staff staff) {

logger.info("Approving employment form {} by staff: {}", id, staff.getId());
return ResponseEntity.ok(
employmentFormService.approveAndFinalizeEmployment(id, staff));
}
Expand All @@ -55,11 +48,8 @@ public ResponseEntity<String> approveForm(@PathVariable Long id, Authentication
public ResponseEntity<EmploymentFormDTO> updateForm(
@PathVariable Long id,
@Valid @RequestBody UpdateEmploymentDTO dto,
Authentication auth) {
Object principal = auth.getPrincipal();
if (!(principal instanceof org.example.cyberwatch.features.staff.model.Staff staff)) {
throw new AccessDeniedException("Principal must be a Staff object");
}
@AuthenticationPrincipal Staff staff) {

EmploymentFormDTO updatedForm = employmentFormService.updateFormBeforeApproval(id, dto, staff);
return ResponseEntity.ok(updatedForm);
}
Expand All @@ -80,12 +70,8 @@ public ResponseEntity<EmploymentFormDTO> getFormById(@PathVariable Long id) {
@PostMapping("/{id}/reject")
public ResponseEntity<String> rejectForm(
@PathVariable Long id,
Authentication authentication) {
@AuthenticationPrincipal Staff staff) {

Object principal = authentication.getPrincipal();
if (!(principal instanceof org.example.cyberwatch.features.staff.model.Staff staff)) {
throw new AccessDeniedException("Principal must be a Staff object");
}

String message = employmentFormService.rejectForm(id, staff);
return ResponseEntity.ok(message);
Expand All @@ -95,11 +81,8 @@ public ResponseEntity<String> rejectForm(
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteForm(
@PathVariable Long id,
Authentication authentication) {
Object principal = authentication.getPrincipal();
if (!(principal instanceof org.example.cyberwatch.features.staff.model.Staff staff)) {
throw new AccessDeniedException("Principal must be a Staff object");
}
@AuthenticationPrincipal Staff staff) {

employmentFormService.deleteForm(id, staff);
return ResponseEntity.noContent().build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ public class EmploymentForm {

@Column(name = "social_security_number", nullable = false, unique = true)
@NotBlank(message = "Social security number cannot be blank")
@Pattern(regexp = "\\d{6}-\\d{4}|\\d{8}-\\d{4}", message = "Social security number must be in format YYMMDD-NNNN or YYYYMMDD-NNNN")
private String socialSecurityNumber;

@Column(name = "first_name")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.RandomStringUtils;
import org.example.cyberwatch.config.security.EncryptionService;
import org.example.cyberwatch.features.form.dto.CreateEmploymentDTO;
import org.example.cyberwatch.features.form.dto.EmploymentFormDTO;
import org.example.cyberwatch.features.form.dto.UpdateEmploymentDTO;
Expand All @@ -16,6 +17,7 @@
import org.example.cyberwatch.shared.model.enums.Role;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
Expand All @@ -38,6 +40,7 @@ public class EmploymentFormService {
private final S3Service s3Service;
private final ObjectMapper objectMapper;
private final PasswordEncoder passwordEncoder;
private final EncryptionService encryptionService;

//Create employment form
@Transactional
Expand All @@ -57,11 +60,14 @@ public EmploymentFormDTO createForm(CreateEmploymentDTO form, Staff hrStaff) {
// Set default status to PENDING
formEntity.setStatus(ApprovalStatus.PENDING);
formEntity.setCreatedBy(hrStaff);
formEntity.setSocialSecurityNumber(
encryptionService.encrypt(form.getSocialSecurityNumber())
);

EmploymentFormDTO savedForm = employmentMapper.toDTO(employmentFormRepository.save(formEntity));
EmploymentForm savedForm = employmentFormRepository.save(formEntity);
logger.info("New employment form created with ID: {} by HR staffId={}", savedForm.getId(), hrStaff.getId());

return savedForm;
return toSafeDto(savedForm);
}

@PreAuthorize("hasAnyRole('HR', 'CEO', 'CTO', 'ADMIN')")
Expand All @@ -84,7 +90,7 @@ private List<EmploymentFormDTO> getAllForms() {
// Get a single form by ID
@PreAuthorize("hasAnyRole('HR', 'CEO', 'CTO', 'ADMIN')")
public EmploymentFormDTO getFormById(Long formId) {
return employmentMapper.toDTO(findFormById(formId));
return toSafeDto(findFormById(formId));
}

// Update form before approval (only PENDING forms can be updated)
Expand All @@ -110,18 +116,20 @@ public EmploymentFormDTO updateFormBeforeApproval(Long formId, UpdateEmploymentD
&& Objects.equals(existingForm.getCreatedBy().getId(), loggedInHr.getId());

if (!isAdmin && !isCreator) {
throw new IllegalStateException("Only the HR staff who created this form or an admin can update it");
throw new AccessDeniedException("Only the HR staff who created this form or an admin can update it");
}

String existingSsnPlain = encryptionService.decrypt(existingForm.getSocialSecurityNumber());
String newSsnPlain = updatedForm.getSocialSecurityNumber();
// Check for duplicate SSN if it's changed
if (!existingForm.getSocialSecurityNumber().equals(updatedForm.getSocialSecurityNumber())) {
validateSsnNotExists(updatedForm.getSocialSecurityNumber());
if (!existingSsnPlain.equals(newSsnPlain)) {
validateSsnNotExists(newSsnPlain);
existingForm.setSocialSecurityNumber(encryptionService.encrypt(newSsnPlain));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

employmentMapper.updateEntity(updatedForm, existingForm);

logger.info("Form {} updated by staffId={}", formId, loggedInHr.getId());
return employmentMapper.toDTO(employmentFormRepository.save(existingForm));
return toSafeDto(employmentFormRepository.save(existingForm));
}

// Reject a form (only PENDING forms can be rejected, and only by management)
Expand Down Expand Up @@ -227,14 +235,34 @@ private String generateSecurePassword() {
}

private void validateSsnNotExists(String ssn) {
if (employmentFormRepository.existsBySocialSecurityNumber(ssn)) {
throw new IllegalStateException("An application with this SSN already exists.");

// TODO: Replace with SSN hash lookup when database schema is updated
// Current workaround: encrypt before searching (requires double encryption in createForm which is costly)
// Check employment forms by decrypting and comparing
List<EmploymentForm> existingForms = employmentFormRepository.findAll();
for (EmploymentForm form : existingForms) {
String decryptedSsn = encryptionService.decrypt(form.getSocialSecurityNumber());
if (decryptedSsn.equals(ssn)) {
throw new IllegalStateException("An application with this SSN already exists.");
}
}
if (staffRepository.existsBySocialSecurityNumber(ssn)) {
throw new IllegalStateException("An employee with this SSN already exists.");

// Check staff by decrypting and comparing
List<Staff> existingStaff = staffRepository.findAll();
for (Staff staff : existingStaff) {
String decryptedSsn = encryptionService.decrypt(staff.getSocialSecurityNumber());
if (decryptedSsn.equals(ssn)) {
throw new IllegalStateException("An employee with this SSN already exists.");
}
}
Comment thread
codebyNorthsteep marked this conversation as resolved.
}

private EmploymentFormDTO toSafeDto(EmploymentForm form) {
EmploymentFormDTO dto = employmentMapper.toDTO(form);
dto.setSocialSecurityNumber(encryptionService.maskLastFour(form.getSocialSecurityNumber()));
return dto;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private void archiveToS3(EmploymentForm form) {
try {
// Rewrite form-data to json
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ public StaffRestController(StaffService staffService) {
}

@GetMapping("/me")
//skapa en ny metod i service
public ResponseEntity<StaffDTO> getCurrentUser(@AuthenticationPrincipal Staff staff) {
if (staff == null) {
return ResponseEntity.status(401).build();
}
return ResponseEntity.ok(staffService.getStaffById(staff.getId()));
return ResponseEntity.ok(staffService.getUserStaff(staff));
}

@PatchMapping("/me/status")
Expand All @@ -41,17 +42,20 @@ public ResponseEntity<StaffDTO> updateMyStatus(@AuthenticationPrincipal Staff st
if (!List.of("ONLINE", "BUSY", "AWAY", "OFFLINE").contains(status)) {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.ok(staffService.updateStatus(staff.getId(), status));
return ResponseEntity.ok(staffService.updateStatus(staff.getId(), status, staff));
}

@GetMapping("/{id}")
public ResponseEntity<StaffDTO> getStaffById(@PathVariable Long id) {
return ResponseEntity.ok(staffService.getStaffById(id));
public ResponseEntity<StaffDTO> getStaffById(@PathVariable Long id,
@AuthenticationPrincipal Staff user) {

return ResponseEntity.ok(staffService.getStaffById(id, user));

}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@PutMapping("/{id}")
public ResponseEntity<StaffDTO> updateStaff(@PathVariable Long id, @Valid @RequestBody UpdateStaffDTO dto) {
return ResponseEntity.ok(staffService.updateStaff(id, dto));
public ResponseEntity<StaffDTO> updateStaff(@PathVariable Long id, @Valid @RequestBody UpdateStaffDTO dto, @AuthenticationPrincipal Staff staff) {
return ResponseEntity.ok(staffService.updateStaff(id, dto, staff));
}

@DeleteMapping("/{id}")
Expand All @@ -63,7 +67,8 @@ public ResponseEntity<Void> deleteStaff(@PathVariable Long id) {
@GetMapping
public ResponseEntity<List<StaffDTO>> getStaffByRoleOrDepartment(
@RequestParam(required = false) Role role,
@RequestParam(required = false) Department department) {
return ResponseEntity.ok(staffService.getStaffByRoleOrDepartment(role, department));
@RequestParam(required = false) Department department,
@AuthenticationPrincipal Staff user) {
return ResponseEntity.ok(staffService.getStaffByRoleOrDepartment(role, department, user));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ public class Staff {

@Column(name = "social_security_number", nullable = false, unique = true)
@NotBlank(message = "Social security number cannot be blank")
@Pattern(regexp = "\\d{6}-\\d{4}|\\d{8}-\\d{4}", message = "Social security number must be in format YYMMDD-NNNN or YYYYMMDD-NNNN")
private String socialSecurityNumber;

@Column(name = "first_name")
Expand Down
Loading
Loading