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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ target/
!**/src/main/**/target/
!**/src/test/**/target/

logs/

### STS ###
.apt_generated
.classpath
Expand Down
5,028 changes: 0 additions & 5,028 deletions logs/cyberwatch.log

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
// Google OAuth2-flödets endpoints måste vara öppna
.requestMatchers("/oauth2/**", "/login/**").permitAll()
// Systemloggsidan ska endast vara synlig för ADMIN
// — måste stå INNAN /pages/** wildcarden nedan, annars träffar wildcarden först
// — måste stå INNAN /pages/** wildcarden nedan
.requestMatchers("/pages/logs.html").hasRole("ADMIN")
// Statiska filer och frontend-sidor
.requestMatchers(
Expand All @@ -62,6 +62,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
).permitAll()
// Actuator-endpoints – endast ADMIN får se systemloggar
.requestMatchers("/actuator/**").hasRole("ADMIN")
// Alla inloggade får hämta sin egen profil
.requestMatchers(HttpMethod.GET, "/api/staff/me").authenticated()
// Alla inloggade får uppdatera sin egen status (online/borta/offline)
// — måste stå INNAN den generella /api/staff/** regeln nedan
.requestMatchers(HttpMethod.PATCH, "/api/staff/me/status").authenticated()
// Alla inloggade får läsa staff (behövs för ticket-dropdowns)
.requestMatchers(HttpMethod.GET, "/api/staff/**").authenticated()
// Endast HR, CEO, CTO & ADMIN får skriva/ändra staff
Expand Down
8 changes: 6 additions & 2 deletions src/main/java/org/example/cyberwatch/config/WebConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ public class WebConfig implements WebMvcConfigurer {
public void addViewControllers(ViewControllerRegistry registry) {
// Mappa rot-URL till index.html
registry.addViewController("/").setViewName("forward:/index.html");

// Valfritt: Om man vill kunna skriva /login istället för /pages/login.html
registry.addViewController("/login").setViewName("forward:/pages/login.html");
registry.addViewController("/dashboard").setViewName("forward:/pages/dashboard.html");

// Felhanteringssidor — ersätter Whitelabel Error Page
registry.addViewController("/error/404").setViewName("forward:/pages/404.html");
registry.addViewController("/error/500").setViewName("forward:/pages/500.html");
Comment thread
gitnes94 marked this conversation as resolved.
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import org.example.cyberwatch.features.ticket.model.Ticket;
import org.example.cyberwatch.features.ticket.repository.TicketRepository;
import org.example.cyberwatch.shared.model.enums.Role;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -15,20 +17,16 @@
*
* Förhöjda roller (ADMIN, CEO, CTO):
* – Ser och kan ändra ALLA ärenden oavsett tilldelning
* – CEO och CTO behöver ledningsöversikt över alla pågående ärenden
* – ADMIN har dessutom exklusiv rätt att tilldela och radera ärenden
*
* Standardroller (HR, PROJECT_MANAGER, CONSULTANT):
* – Ser och kan ändra BARA ärenden de skapat eller är tilldelade till
* – Principen om minsta möjliga åtkomst för att skydda sekretess
*
* @Transactional(readOnly = true) på publika metoder säkerställer att
* lazy-laddade relationer (assignedStaff) kan nås utan LazyInitializationException,
* oavsett om spring.jpa.open-in-view är aktiverat eller inte.
* – Nekade åtkomstförsök loggas med staffId och roll
*/
@Component("ticketSecurity")
public class TicketSecurityService {

private static final Logger log = LoggerFactory.getLogger(TicketSecurityService.class);

private final TicketRepository ticketRepository;

public TicketSecurityService(TicketRepository ticketRepository) {
Expand All @@ -37,46 +35,61 @@ public TicketSecurityService(TicketRepository ticketRepository) {

/**
* Kontrollerar om den inloggade användaren får se eller ändra ett specifikt ärende.
* Används av @PreAuthorize på endpoints som tar ticketId som path variable.
*
* @param authentication Spring Security-kontexten
* @param ticketId ID på det ärende som ska nås
* @return true om åtkomst tillåts, false annars (→ 403)
* Nekade försök loggas med staffId, roll och ticketId.
*/
@Transactional(readOnly = true)
public boolean canAccess(Authentication authentication, Long ticketId) {
if (!(authentication.getPrincipal() instanceof Staff requester)) return false;
if (isElevatedRole(requester.getRole())) return true;

return ticketRepository.findById(ticketId)
.map(ticket -> hasAccess(ticket, requester))
.map(ticket -> {
boolean access = hasAccess(ticket, requester);
if (!access) {
log.warn("Åtkomst nekad för staffId={} role={} — försökte nå ticketId={}",
requester.getId(), requester.getRole(), ticketId);
}
return access;
})
.orElse(false);
}

/**
* Samma behörighetskontroll som canAccess men för endpoints som
* identifierar ärendet via ticketCode istället för ID.
*
* @param authentication Spring Security-kontexten
* @param ticketCode ärendekoden, t.ex. "TICKET-1042"
* @return true om åtkomst tillåts, false annars (→ 403)
*/
@Transactional(readOnly = true)
public boolean canAccessByCode(Authentication authentication, String ticketCode) {
if (!(authentication.getPrincipal() instanceof Staff requester)) return false;
if (isElevatedRole(requester.getRole())) return true;

return ticketRepository.findByTicketCode(ticketCode)
.map(ticket -> hasAccess(ticket, requester))
.map(ticket -> {
boolean access = hasAccess(ticket, requester);
if (!access) {
log.warn("Åtkomst nekad för staffId={} role={} — försökte nå ticketCode={}",
requester.getId(), requester.getRole(), ticketCode);
}
return access;
})
.orElse(false);
}

/**
* Kontrollerar om en användare är skapare eller tilldelad handläggare på ett ärende.
*
* Privat hjälpmetod som eliminerar dupliceringen mellan canAccess och canAccessByCode.
* Anropas alltid inom en aktiv transaktion så att lazy-laddning av assignedStaff fungerar.
* Kontrollerar om den inloggade användaren är ADMIN.
* Loggar vem som nekades om de inte är det — till skillnad från
* hasRole('ADMIN') som kastar exception utan att logga staffId.
*/
public boolean isAdmin(Authentication authentication) {
if (!(authentication.getPrincipal() instanceof Staff staff)) return false;
boolean admin = staff.getRole() == Role.ADMIN;
if (!admin) {
log.warn("Åtkomst nekad för staffId={} role={} — kräver ADMIN-roll",
staff.getId(), staff.getRole());
}
return admin;
}

private boolean hasAccess(Ticket ticket, Staff requester) {
boolean isOwner = ticket.getCreatedBy() != null &&
ticket.getCreatedBy().getId().equals(requester.getId());
Expand All @@ -85,12 +98,6 @@ private boolean hasAccess(Ticket ticket, Staff requester) {
return isOwner || isAssigned;
}

/**
* Förhöjda roller har bredare åtkomst än standardroller.
* ADMIN – full systemkontroll
* CEO – ledningsöversikt och eskalering
* CTO – teknisk ledningsöversikt
*/
private boolean isElevatedRole(Role role) {
return role == Role.ADMIN || role == Role.CEO || role == Role.CTO;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ public ResponseEntity<Map<String, String>> handleValidationErrors(
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors);
}

// This method now returns valid enum values dynamically based on the actual enum type that failed binding
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<Map<String, String>> handleTypeMismatch(
MethodArgumentTypeMismatchException ex) {
Expand Down Expand Up @@ -93,7 +92,7 @@ public ResponseEntity<Map<String, String>> handleIllegalArgument(IllegalArgument
.body(Map.of("error", ex.getMessage()));
}

@ExceptionHandler(org.springframework.security.access.AccessDeniedException.class)
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<Map<String, String>> handleAccessDenied(AccessDeniedException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("error", "Access denied"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import org.example.cyberwatch.features.activitylog.model.ActivityLogResponseDTO;
import org.example.cyberwatch.features.activitylog.service.ActivityLogService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import java.util.List;
Expand All @@ -17,6 +18,7 @@ public ActivityLogController(ActivityLogService activityLogService) {
this.activityLogService = activityLogService;
}

@PreAuthorize("@ticketSecurity.canAccess(authentication, #ticketId)")
@GetMapping
public ResponseEntity<List<ActivityLogResponseDTO>> getLogs(@PathVariable Long ticketId) {
return ResponseEntity.ok(activityLogService.getLogsForTicket(ticketId));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ public void logFileUpload(Ticket ticket, Staff uploadedBy, String fileName) {
activityLogRepository.save(log);
}

//TicketDTO instead of Ticket-entity
public void logComment(Ticket ticket, Staff performedBy, String commentText) {
ActivityLog log = new ActivityLog(ticket, performedBy, ActivityType.COMMENT_ADDED, commentText);
activityLogRepository.save(log);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import org.example.cyberwatch.features.staff.model.Staff;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

Expand All @@ -22,17 +23,17 @@ public CommentController(CommentService commentService) {
this.commentService = commentService;
}

@PreAuthorize("@ticketSecurity.canAccess(authentication, #ticketId)")
@PostMapping
public ResponseEntity<CommentResponseDTO> addComment(
@PathVariable Long ticketId,
@Valid @RequestBody CommentDTO dto,
// Hämtar den inloggade Staff-instansen direkt från SecurityContext
// — sätts upp av JwtAuthFilter på varje autentiserad request
@AuthenticationPrincipal Staff author) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(commentService.addComment(ticketId, dto, author));
}

@PreAuthorize("@ticketSecurity.canAccess(authentication, #ticketId)")
@GetMapping
public ResponseEntity<List<CommentResponseDTO>> getComments(@PathVariable Long ticketId) {
return ResponseEntity.ok(commentService.getCommentsForTicket(ticketId));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,17 @@ public ResponseEntity<TicketResponseDTO> reopen(@PathVariable Long id) {
return ResponseEntity.ok(ticketService.reopenTicket(id, getAuthenticatedStaffId()));
}

@PreAuthorize("hasRole('ADMIN')")
// Ersatt hasRole('ADMIN') med isAdmin() så att nekade försök loggas med staffId
@PreAuthorize("@ticketSecurity.isAdmin(authentication)")
@PutMapping("/{ticketId}/assign")
public ResponseEntity<TicketResponseDTO> assignTicket(
@PathVariable Long ticketId,
@Valid @RequestBody AssignTicketDTO dto) {
return ResponseEntity.ok(ticketService.assignTicket(ticketId, dto.getStaffIds(), getAuthenticatedStaffId()));
}

@PreAuthorize("hasRole('ADMIN')")
// Ersatt hasRole('ADMIN') med isAdmin() så att nekade försök loggas med staffId
@PreAuthorize("@ticketSecurity.isAdmin(authentication)")
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTicket(@PathVariable Long id) {
ticketService.deleteTicket(id);
Expand All @@ -111,10 +113,8 @@ public ResponseEntity<?> uploadFile(
} catch (TicketNotFoundException e) {
throw e;
} catch (IllegalArgumentException e) {
// Här fångar vi valideringsfel och returnerar det specifika meddelandet
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
} catch (Exception e) {
// Generellt fel för I/O eller S3-problem (500 Internal Server Error)
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Uppladdning misslyckades på grund av ett tekniskt fel."));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ public TicketResponseDTO advanceTicketStatus(Long id, Long performedById) {
Ticket saved = ticketRepository.save(ticket);
activityLogService.logStatusChange(saved, performer, oldStatus, saved.getStatus());

log.info("Ticket {} status avancerad: {} → {}", saved.getTicketCode(), oldStatus, saved.getStatus());
log.info("Ticket {} status avancerad: {} → {} av staffId={}", saved.getTicketCode(), oldStatus, saved.getStatus(), performedById);
Comment thread
gitnes94 marked this conversation as resolved.

return TicketResponseDTO.from(saved);
}
Expand All @@ -152,7 +152,7 @@ public TicketResponseDTO setTicketStatus(Long id, Status newStatus, Long perform
Ticket saved = ticketRepository.save(ticket);
activityLogService.logStatusChange(saved, performer, oldStatus, newStatus);

log.info("Ticket {} status ändrad: {} → {}", saved.getTicketCode(), oldStatus, newStatus);
log.info("Ticket {} status ändrad: {} → {} av staffId={}", saved.getTicketCode(), oldStatus, newStatus, performedById);

return TicketResponseDTO.from(saved);
}
Expand All @@ -165,7 +165,7 @@ public TicketResponseDTO reopenTicket(Long id, Long performedById) {
Ticket saved = ticketRepository.save(ticket);
activityLogService.logStatusChange(saved, performer, oldStatus, Status.REOPENED);

log.info("Ticket {} återöppnad", saved.getTicketCode());
log.info("Ticket {} återöppnad av staffId={}", saved.getTicketCode(), performedById);

return TicketResponseDTO.from(saved);
}
Expand All @@ -179,7 +179,7 @@ public TicketResponseDTO assignTicket(Long ticketId, List<Long> staffIds, Long a
Ticket saved = ticketRepository.save(ticket);
activityLogService.logAssignmentChange(saved, assigner, staffList);

log.info("Ticket {} tilldelad till {} person(er)", saved.getTicketCode(), staffList.size());
log.info("Ticket {} tilldelad till {} person(er) av staffId={}", saved.getTicketCode(), staffList.size(), assignedById);

return TicketResponseDTO.from(saved);
}
Expand Down Expand Up @@ -218,8 +218,7 @@ public Map<String, Object> uploadFile(Long ticketId, Long uploadedById, Multipar

activityLogService.logFileUpload(ticket, uploader, originalFileName);

// Loggar attachmentId istället för filnamnet för att undvika att läcka användardata i loggar
log.info("Fil uppladdad till ticket {}: attachmentId={}", ticket.getTicketCode(), saved.getId());
log.info("Fil uppladdad till ticket {}: attachmentId={} av staffId={}", ticket.getTicketCode(), saved.getId(), uploadedById);

return Map.of(
"message", "Filen laddades upp.",
Expand All @@ -233,7 +232,6 @@ public void deleteTicket(Long id) {
Ticket ticket = ticketRepository.findById(id).orElseThrow(() -> new TicketNotFoundException(id));
String ticketCode = ticket.getTicketCode();
ticketRepository.delete(ticket);
// Loggar efter delete så att koden bara körs om raderingen lyckades
log.info("Ticket {} raderad", ticketCode);
}

Expand Down
45 changes: 45 additions & 0 deletions src/main/resources/logback-spring.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>

<!-- Konsolloggning (behålls för lokal utveckling / Docker-loggar) -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>

<!-- Filloggning med automatisk rotation -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- Aktiv loggfil — samma sökväg som logging.file.name i application.properties -->
<file>logs/cyberwatch.log</file>

<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!-- Rullas vid midnatt eller när filen når maxFileSize -->
<!-- %i är ett löpnummer om flera filer skapas samma dag -->
<fileNamePattern>logs/cyberwatch.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<!-- Max storlek per fil innan rotation -->
<maxFileSize>10MB</maxFileSize>
<!-- Antal dagar att behålla gamla loggfiler -->
<maxHistory>30</maxHistory>
<!-- Totalt diskutrymme för alla loggfiler tillsammans -->
<totalSizeCap>100MB</totalSizeCap>
</rollingPolicy>

<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>

<!-- Minska brus från Spring/Hibernate i produktion -->
<logger name="org.springframework" level="WARN"/>
<logger name="org.hibernate" level="WARN"/>

<!-- Er applikationskod loggar på INFO -->
<logger name="org.example.cyberwatch" level="INFO"/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.

<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>

</configuration>
Comment thread
gitnes94 marked this conversation as resolved.
5 changes: 4 additions & 1 deletion src/main/resources/static/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,16 @@ async function loadDashboardTickets() {

const assignedStaffNames = t.assignedStaff?.map(s => escapeHtml(s.fullName)).join(', ') || 'Ingen';
const assignedIds = t.assignedStaff?.map(s => s.id) || [];
const createdByName = escapeHtml(t.createdBy?.fullName || 'Okänd');

item.innerHTML = `
<div class="ticket-main"
role="link"
tabindex="0"
aria-label="Ticket ${t.id}: ${escapeHtml(t.title)}"
onclick="window.location.href='/pages/ticket-detail.html?id=${t.id}'"
onkeydown="if(event.key==='Enter'||event.key===' ')window.location.href='/pages/ticket-detail.html?id=${t.id}'"> <div class="ticket-header">
onkeydown="if(event.key==='Enter'||event.key===' ')window.location.href='/pages/ticket-detail.html?id=${t.id}'">
<div class="ticket-header">
<span class="badge badge-${t.status}">${t.status}</span>
<span class="ticket-title">${escapeHtml(t.title)}</span>
</div>
Expand All @@ -91,6 +93,7 @@ async function loadDashboardTickets() {
</div>
</div>
<div class="ticket-actions">
<span class="ticket-created-by">Skapad av: <strong>${createdByName}</strong></span>
<select class="dashboard-assignment-select" data-id="${t.id}" style="max-width: 150px;">
<option value="">Tilldela...</option>
${allStaff.map(staff => {
Expand Down
Loading